From 4d963e9ed7dc7aa49557b0bec1a8c16eb1580e1e Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 04:47:14 +0200 Subject: [PATCH 01/55] Prepare auto-ban response policy --- dev/WORKLOG.md | 58 ++---------------------- dev/WORKLOG_HISTORY.md | 7 ++- dev/draft/security-hardening/auto-ban.md | 3 +- 3 files changed, 12 insertions(+), 56 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index c0f8dd68..ee448c47 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -81,60 +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. -- Addressed follow-up rate-limit review findings: pre-setup ordinary wizard navigation remains skipped, but the final `POST /setup/review` apply action can now consume the DB-ready/default-backed setup-apply limiter before setup completes, and the scheduler interval intent/bucket is scoped to the exact `/cron/run` route so `/cron/*` misses cannot poison legitimate scheduler triggers. -- Hardened pre-setup HTTP error rendering: all known `4xx`/`5xx` statuses rendered through the shared browser error renderer now return minimal DB-free HTML `no-store` responses with status text and a Request ID resolved through `AccessRequestMetadata` before setup completion, avoiding custom content/error-page rendering while the database may be unavailable; pre-setup rate-limit/probe `400`/`429` responses reuse that same bare renderer path. -- Added the public `HttpErrorRenderer::resolve()` entry point for browser error-page resolution and migrated existing browser error triggers from direct render calls to that single resolver path; API JSON error rendering remains separate through the API responder, and callers can force the minimal bare response for future block surfaces such as auto-ban. -- Addressed follow-up rate-limit review findings: all non-empty `Authorization` API preflights now classify by `Access-Control-Request-Method` for rate-limit buckets so non-Bearer credentialed preflights cannot bypass write/admin budgets, and submitted-account workflow buckets now consume local Visitor/IP guards before account/email subjects so locally blocked clients cannot poison other users' shared login, registration, or password-reset buckets. -- Verification: PHP syntax checks for changed abuse/rate-limit classes and tests; focused PHPUnit for request classification, API CORS, and rate-limit enforcer coverage passed with 89 tests and 474 assertions; `php bin/console lint:container --env=test --no-debug`; focused `bin/lint` for changed PHP/Markdown files; full `php bin/phpunit` passed with 1567 tests and 10342 assertions. -- Addressed follow-up rate-limit review findings: account-token workflows now add HMAC-redacted submitted-account token subjects for `/user/invitation/{token}` and `/user/reset-password/{token}`, scheduler JSON rate-limit responses use segment-bound `/cron` matching so browser content such as `/cronjobs` stays HTML, and rate enforcement now pre-checks all planned descriptor/subject consumes before committing the batch so later global bucket rejections do not spend earlier workflow/account buckets. -- Verification: PHP syntax checks for changed abuse/rate-limit classes and tests; focused PHPUnit for subject resolution, limiter factory, enforcer, response renderer, request subscriber, and controller enforcement coverage passed with 90 tests and 928 assertions; `php bin/console lint:container --env=test --no-debug`; focused `bin/lint` for changed PHP/Markdown files; `git diff --check`; full `php bin/phpunit` passed with 1578 tests and 10442 assertions. -- Documented the intentional multi-bucket consume trade-off: the pre-check/commit flow prevents repeatable partial spends and account-bucket poisoning without adding cross-bucket transaction/rollback complexity; the residual concurrent-request race is bounded by per-key limiter locks and accepted as non-practical for repeated unrelated account bucket draining. -- Consolidated API effective-method handling into `ApiRequestMethodPolicy` so API CORS, read-only API key gating, abuse intent classification, and read-only Owner rate-limit exceptions share the same path-bound API v1, Authorization-header, credentialed OPTIONS, and `Access-Control-Request-Method` semantics. -- Added shared segment-bound `PathScopeMatcher` routing helper and moved API v1 detection plus rate-limit technical exclusions/JSON response-surface checks onto raw technical path matching so `/api/v10`, `/cronjobs`, `/_wdtfoo`, localized public lookalikes, and similar paths do not inherit protected path behavior by raw prefix accident. -- Moved rate-limit enforcement-stage eligibility and subject-selection policy onto bucket descriptors through `RateLimitSubjectPolicy`, keeping login/auth-failure, recovery-render, API/admin auth-failure, scheduler credential/IP anchoring, submitted-account workflows, and authenticated multipliers centrally declared with the bucket policy instead of duplicated in the stage enum and selector. -- Added HMAC-redacted submitted-account token subjects for `POST /user/security-review/{token}` and route-attributed localized security-review posts, matching the existing invitation/reset token workflow handling so leaked review-token submissions share the intended password-reset limiter bucket across visitors/IPs. -- Added shared `RequestPathResolver` request-segment resolution with gated URL locale-prefix stripping for locale-prefix UI/account scopes; API/Cron/Setup/static technical scopes remain raw prefixless route scopes that still use the resolved request locale, while access-log surface detection, request-intent classification, scheduler credential scoping, and submitted-account workflow subject detection share exact path-part semantics. -- Addressed follow-up rate-limit review findings: localized Cron/API lookalike paths no longer spend scheduler/API buckets or receive scheduler/API JSON responses, adjacent API v1 and scheduler guards now use segment-bound helpers instead of raw `str_starts_with()` prefixes, descriptorless Admin/Editor navigation now falls back to the global website buckets, and suspicious-probe handling runs before package loading with a forced minimal `400 Invalid Request` response while preserving passive probe signal recording. - -### 2026-06-17 feat-security-rate-enforcement -- Started the rate-enforcement slice after `feat-security-admin-acl-enforcement` merged, archived the completed Admin ACL branch notes into `dev/WORKLOG_HISTORY.md`, and refreshed the active worklog for the new branch. -- Updated `composer.lock` after dependency resolution refreshed `guzzlehttp/psr7` to 2.12.0 and `justinrainbow/json-schema` to 6.10.0. -- Recorded rate-enforcement product decisions: exact `/user/login?bypass=1` recovery path, fail-open limiter-storage degradation, one Owner-gated rate-limit mode setting with `off`/`standard`/`strict`/`panic`, and a dedicated rate-limit policy catalogue that keeps bucket budgets/profile scaling separate from semantic action costs. -- Added the rate-limit policy catalogue, profile scaling, Owner-gated Security setting, descriptor-backed Symfony limiter facade, request subscriber, redacted HTML/JSON `429`/probe `400` responses, fail-open diagnostics, Owner ordinary exemption, authenticated-user multiplier, `/api/live/**`/prefetch exclusions, login-success reset, and the dormant verified-provider captcha reset interface. -- Added a test-environment opt-in header for the request subscriber so legacy functional tests that mutate Security settings or share synthetic visitors are not affected by global limiter state; production and development enforcement are unchanged. -- Verification: focused syntax/lint checks for rate-limit, settings, message, translation, response, docs, and worklog files; focused PHPUnit for rate-limit catalogue/enforcer/reset, Security settings registry/form/API/UI, message catalogues, and HTTP enforcement responses; `php bin/console lint:container --env=test --no-debug`; `php bin/console render:route /admin/settings/security --role=owner --env=test --no-debug --include-status`; `bin/lint --diff`; full `php bin/phpunit` passed with 1424 tests and 9326 assertions. -- Hardened review-sensitive rate enforcement details: `/cron/run` is no longer Owner-exempt and now uses explicit scheduler intervals (`standard` 1/minute, `strict` 1/15 minutes, `panic` 1/hour), rate-policy descriptors are generated from user-visible action counts plus unique action-cost multipliers with a single-action profile floor, limiter degradation diagnostics report through the Message layer, and shared rendered HTTP error pages set `no-store` centrally. -- Verification: focused PHPUnit for action-cost catalogue, rate-limit catalogue/enforcer/reset, public error pages, rate-limit response controllers, and message catalogues; full `php bin/phpunit` passed with 1431 tests and 9422 assertions. -- Adjusted suspicious-probe profile scaling so strict/panic extend the probe window while the single-action credit floor prevents Symfony limiter consume failures below the probe action cost. -- Extended `render:route` with request-header input and response-header output, then added CLI route-render coverage proving repeated `/cron/run` renders with Owner context and mutable Owner API key receive scheduler `429` with `Retry-After` and `no-store` instead of bypassing through the Owner exemption. -- Added a production-environment guard to `render:route` so the debug renderer fails closed in `APP_ENV=prod` and remains available only for development/test diagnostics. -- Clarified the scheduler rate-limit policy documentation: `/cron/run` uses an operational pre-auth interval guard, and legitimate scheduler `429` responses in strict/panic modes are not treated as abuse or security signals. -- Addressed first Cloud Review rate-limit findings: auth workflow buckets now charge only unsafe submissions, the limiter runs after routing but before Symfony authentication failures, `/build/**` is excluded with generated assets, active-profile descriptors are used for login/captcha resets, and `/user/login?bypass=1` is wired to the dedicated recovery-login buckets. -- Hardened adjacent route-guard coverage so content routes cannot claim technical/static namespaces such as `/assets/**`, `/build/**`, `/_profiler/**`, `/profiler/**`, and `/_wdt/**` while relying on limiter exclusions. -- Addressed follow-up Cloud Review bypass findings: suspicious probes now run before ordinary `/api/live/**` exclusions, failed login/API credentials charge through `LoginFailureEvent`, ordinary buckets run after Symfony authentication so Owner/API-key subjects and authenticated multipliers are available, login/registration/password-reset buckets include HMAC-redacted submitted-account subjects, invalid API prefixes no longer become primary API limiter subjects, and high-impact Admin upload/download paths are classified before broad package/admin buckets. -- Verification: focused syntax checks, focused PHPUnit coverage for request classification, rate-limit enforcer/reset/controller behavior, render-route cron handling, content route guards, and test seed isolation; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; full `php bin/phpunit` passed with 1458 tests and 9679 assertions. -- Addressed additional Cloud Review hardening findings: authentication-failure rate checks now include Admin API mutation/upload/download families, successful login resets the same submitted-account/visitor/IP login keys used by enforcement, persisted Symfony limiter IDs include the active descriptor shape so profile changes do not reuse stale fixed-window state, and consume operations use Symfony's configured lock factory. -- Verification: PHP syntax checks for changed rate-limit classes/tests; focused PHPUnit for limiter factory, reset, controller enforcement, enforcer, and request classification coverage passed with 75 tests and 594 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1462 tests and 9717 assertions. -- Addressed the next Cloud Review rate-limit bypass findings: Bearer-bearing `OPTIONS` requests now classify as API authentication attempts instead of anonymous CORS preflights, recovery-login `GET` renders spend the dedicated recovery bucket while avoiding website buckets, Admin API auth failures add IP anchoring, read-only Owner API-key write denials spend the write/admin bucket before the 403 while read-write Owner keys stay ordinary-exempt, and scheduler intervals key on HMAC-redacted submitted scheduler credentials with IP fallback/secondary anchoring. -- Verification: PHP syntax checks for changed abuse/rate-limit classes and tests; focused PHPUnit for request classification, subject resolution, rate-limit enforcer/controller enforcement, scheduler controller, and read-only API method coverage passed with 100 tests and 698 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; full `php bin/phpunit` passed with 1473 tests and 9820 assertions. -- Tightened recovery-login accounting so only `GET /user/login?bypass=1` uses the recovery render bucket; unsafe bypass submissions remain normal login attempts, and Panic mode explicitly keeps one recovery render plus the first login submission within budget. -- Narrowed ordinary rate-limit technical path exclusions to exact path segments so generated/static prefixes such as `/_profiler` and `/_wdt` do not accidentally cover similarly named public routes. -- Added the missing `admin.settings.*` source/runtime translations for Security settings fields and options touched by this branch so the Owner-gated Security settings page renders localized Captcha, rate-limit, audit, signal-retention, and probe-pattern controls instead of raw translation keys. -- Verification: PHP syntax checks for changed request-classifier/rate-limit classes and tests; focused PHPUnit for request classification, rate-limit policy/request-subscriber/enforcer/controller behavior, content route guards, and settings coverage passed with 163 tests and 1364 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `php bin/console render:route /admin/settings/security --role=owner --env=test --no-debug --include-status`; `php bin/console render:route '/user/login?bypass=1' --env=test --no-debug --include-status --header 'X-Rate-Limit-Testing: 1'`; full `php bin/phpunit` passed with 1489 tests and 9847 assertions. -- Addressed the latest Cloud Review hardening findings: sensitive recovery-login and Admin export/download/diagnostic `GET` requests now classify before spoofable prefetch forgiveness, derived profile floors now keep two costed ordinary actions available while preserving explicit single-action scheduler/probe interval policies, and suspicious-probe blocking runs before API availability, setup redirect, maintenance, live/API exclusion, and ordinary technical path gates. -- Verification: PHP syntax checks for changed rate-limit/abuse classes and tests; `git diff --check`; focused PHPUnit for request classification, rate-limit policy/request-subscriber/enforcer/reset/factory behavior, and controller enforcement passed with 116 tests and 909 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `php bin/console debug:event-dispatcher kernel.request --env=test --no-debug` confirmed probe priority 900 before response-producing gates; full `php bin/phpunit` passed with 1496 tests and 9941 assertions. -- Documented the future cache panic-mode direction in the frontend delivery/caching draft: Security `panic` is the intended coordination point for a bounded TTL lock that can serve anonymous public traffic from safe cache entries during DDoS-like events while preserving auth, ACL, probe blocking, audit, and Owner/Admin recovery behavior. -- Addressed the CORS preflight rate-limit bypass finding: configured anonymous API CORS preflights still return cheap `204` responses, but `OPTIONS` requests with an actual `Authorization` header are no longer short-circuited by CORS and can reach Bearer authentication failure plus API/Admin rate-limit accounting. -- Verification: PHP syntax checks for changed CORS/rate-limit tests and subscriber; focused PHPUnit for API CORS, request classification, rate-limit enforcer/request subscriber, and controller enforcement passed with 104 tests and 725 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1498 tests and 9960 assertions. -- Addressed the read-only Owner Bearer preflight edge: unsafe `Access-Control-Request-Method` values now count as API write attempts for both read-only API-key method gating and the rate-limit Owner exemption, so read-only Owner keys spend write/admin buckets and receive the same denial shape before repeated attempts become `429`. -- Verification: PHP syntax checks for changed API/rate-limit classes and tests; focused PHPUnit for API read-only method gating, API endpoint access/permission, API CORS, request classification, rate-limit enforcer, and HTTP enforcement passed with 112 tests and 790 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1503 tests and 10005 assertions. -- Addressed the malformed Bearer preflight and signed-in scheduler credential rotation edges: empty/whitespace Bearer API `OPTIONS` requests now classify like the API authenticator's Bearer scheme support and spend the matching API/Admin authentication-failure bucket, while scheduler interval buckets keep IP secondary anchoring even when a user session is present so rotating invalid query credentials cannot bypass `/cron/run` from the same source. -- Verification: PHP syntax checks for changed classifier/rate-limit classes and tests; focused PHPUnit for request classification, API CORS/read-only preflights, abuse subject resolution, rate-limit enforcer/request subscriber/reset/factory behavior, scheduler controller, and HTTP enforcement passed with 138 tests and 862 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1508 tests and 10037 assertions. -- Addressed the next rate-limit review findings and setup safety concern: the early probe hook now prechecks paths with a DB-free default matcher before invoking the full enforcer, pre-setup suspicious probes return a bare generic `400 no-store` without content/error-page DB lookups, ordinary setup wizard traffic is skipped until `APP_SETUP_COMPLETED`, authentication-failure checks no longer apply Owner ordinary exemptions, and only the final review-step setup apply submission is classified into the setup-apply bucket. -- Verification: PHP syntax checks for changed rate-limit/abuse classes and tests; focused PHPUnit for request subscriber, enforcer, classifier, action-cost, HTTP enforcement, and setup redirect behavior passed with 119 tests and 844 assertions; broader Security rate-limit/abuse/API-CORS/read-only/setup focus passed with 169 tests and 1151 assertions; `php bin/console lint:container --env=test --no-debug`; `bin/lint --diff`; `git diff --check`; full `php bin/phpunit` passed with 1514 tests and 10065 assertions. +### 2026-06-18 feat-security-auto-ban +- Started the auto-ban preparation branch after `feat-security-rate-enforcement` merged and archived the completed rate-enforcement branch notes into `dev/WORKLOG_HISTORY.md`. +- Recorded the auto-ban response decision: active temporary bans use the shared browser error renderer's forced bare response path with `403 Forbidden`, `Retry-After` when the TTL is known, `Cache-Control: no-store`, the safe Request ID, and the generic bare context `Request blocked due to suspicious activity. retry-after: ` without exposing score, rule, subject, IP, or signal internals. +- Verification: documentation-only preparation slice; focused Markdown lint passed. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/WORKLOG_HISTORY.md b/dev/WORKLOG_HISTORY.md index e496d5e2..d04cd201 100644 --- a/dev/WORKLOG_HISTORY.md +++ b/dev/WORKLOG_HISTORY.md @@ -1,7 +1,7 @@ # Developer Worklog History > **Status**: Active -> **Updated**: 2026-06-17 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Preserve compacted branch/PR history moved out of `dev/WORKLOG.md` at branch boundaries. @@ -9,6 +9,11 @@ Move completed branch or PR logs from `dev/WORKLOG.md` into this file when switching branches or after a PR is merged. Keep the active worklog focused on the current branch so reviewers can see the full PR context while older project history stays available. ## Archived Branches +### 2026-06-17 to 2026-06-18 feat-security-rate-enforcement +- Implemented the rate-enforcement slice: descriptor-backed Symfony RateLimiter facade, Owner-gated mode setting (`off`, `standard`, `strict`, `panic`), action-cost-derived policy catalogue, Website/API/Scheduler/Auth/Setup/Probe buckets, fail-open storage diagnostics through the Message layer, authenticated multipliers, Owner ordinary exemption, recovery-login handling, active-profile scoped resets, dormant captcha reset contract, and redacted HTML/JSON `429`/probe responses. +- Hardened the branch through 39 resolved Cloud Review findings: unsafe-only workflow charging, authentication-failure ordering, generated/static exclusions, active-profile resets, recovery buckets, account/token subjects, API/CORS/read-only preflight handling, Owner and scheduler exceptions, exact technical path scopes, probe ordering before package/API/setup/maintenance gates, setup-final-apply safety, multi-bucket pre-check/commit semantics, website fallback for descriptor gaps, and segment-bound API/Cron guards. +- Added shared path helpers, HTTP error-renderer bare/resolve behavior, `render:route` diagnostics hardening, rate-limit Security settings translations, future cache-panic documentation, worklog/class-map/draft updates, PR-readiness/review-fix project rules, and final review notes showing full `bin/phpunit`, `bin/jstest`, and `bin/lint` passed before merge. + ### 2026-06-16 to 2026-06-17 feat-security-admin-acl-enforcement - Implemented the Admin ACL enforcement slice: domain-owned feature registry, denied/visible/mutable states, surface inference from feature keys, seeded Owner-configurable defaults, ACL-group override states, Owner-gated `Settings/ACL` matrix, dynamic active-package settings rows, and feature-matrix caching with explicit invalidation. - Wired Admin ACL feature checks through protected settings fields, Admin navigation/views, package/theme actions, package lifecycle and settings, GeoIP maintenance, operations continuations, scheduler, logs, statistics, users, user reviews, ACL group management, backup/status surfaces, and Admin API handlers while keeping visible-only controls rendered disabled where layout depends on them. diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 5a1b0e5d..a5da65c2 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -49,7 +49,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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`. +- Active temporary ban responses default to generic `403 Forbidden` with `Retry-After` when expiry is known, request-family-specific HTML/JSON bodies, redacted diagnostics, and `no-store`. +- Browser ban responses should use the shared `HttpErrorRenderer` forced bare response path by default. The bare context may include the generic text `Request blocked due to suspicious activity. retry-after: ` when a retry delay is known; responses must still include only the safe Request ID/reference and must not expose score values, rule names, subject keys, Visitor IDs, IP buckets, raw IP data, paths, headers, or signal internals. - 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. From 537b019be857e3ec393fc6638bce38b8a9a923ab Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 11:59:05 +0200 Subject: [PATCH 02/55] Document auto-ban scoring policy --- dev/WORKLOG.md | 4 + dev/draft/0.2.x-SecurityHardeningPlan.md | 37 ++--- dev/draft/security-hardening/auto-ban.md | 151 +++++++++++------- .../security-hardening/policy-defaults.md | 63 ++++---- dev/draft/security-hardening/policy-docs.md | 4 +- .../security-hardening/rate-enforcement.md | 8 +- 6 files changed, 154 insertions(+), 113 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index ee448c47..92e98e6f 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -84,6 +84,10 @@ ### 2026-06-18 feat-security-auto-ban - Started the auto-ban preparation branch after `feat-security-rate-enforcement` merged and archived the completed rate-enforcement branch notes into `dev/WORKLOG_HISTORY.md`. - Recorded the auto-ban response decision: active temporary bans use the shared browser error renderer's forced bare response path with `403 Forbidden`, `Retry-After` when the TTL is known, `Cache-Control: no-store`, the safe Request ID, and the generic bare context `Request blocked due to suspicious activity. retry-after: ` without exposing score, rule, subject, IP, or signal internals. +- Updated the auto-ban plan and Security policy defaults for the score-based implementation: suspicious `400`/`403`/`404`/`429` signals feed a one-hour global score, Visitor ID is primary, stable IP scoring is secondary through a laxer threshold multiplier, active bans use cache-flock TTL state, TTLs escalate `1h`/`3h`/`24h`/`7d`, persistent ban-trigger and reset `security_signal_event` records drive escalation and reset cutoffs, threshold changes affect only future ban decisions, trusted registered users default to level `6`/`MANAGER` and are never auto-banned, setup/database degradation fails open, and the resolver-matched `/{LANG}/users/login?bypass=1` recovery-login render path remains reachable. +- Clarified the first score defaults and enforcement ordering: Visitor threshold `100`, IP threshold `x2`, minimum two qualifying signals, error-hit weight `7`, probe/session-copy weight `100`, failed-auth weight `10`; API keys are trusted-user context rather than auto-ban subjects, and active Visitor/IP bans must resolve before error pages or rate-limit bucket consumption but after trusted-user/API-key context can bypass them. +- Tightened review-readiness decisions: scoreable signals are persisted per evaluated source subject so IP scoring uses indexed subject reads instead of JSON context, one evaluation creates at most one active ban with Visitor preferred over IP, active-ban list rendering uses a cache-backed index while per-subject TTL state remains authoritative, and first-slice auto-ban settings/manual resets are Owner-gated. +- Recorded the auto-ban performance policy: score aggregation is triggered only after a scoreable `security_signal_event` write and reuses that DB path for indexed Visitor/IP lookups; ordinary non-signal requests perform only the active-ban cache check and must not start database score queries. - Verification: documentation-only preparation slice; focused Markdown lint passed. ### Archived Compacted Branch History diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index d4812f1a..68dac842 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -1,7 +1,7 @@ # Security hardening implementation plan > **Status**: Draft -> **Updated**: 2026-06-15 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Plan the security hardening feature split so each branch can be implemented, reviewed, and merged as a focused production-ready slice. @@ -35,7 +35,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be u - Recognize cross-action abuse. Separate buckets remain useful, but repeated activity across different guarded workflows should also feed a global subject budget and suspicious-signal store. - Add progressive punishment as a first-class concept: observe, throttle, require captcha, temporarily block, and hard-block only when signals justify it. - Support active punishment such as draining a suspicious subject's relevant buckets when clear bot behavior is detected. -- Add temporary auto-bans with TTL for IP, visitor ID, API key, or combined subjects. Authenticated users should receive softer handling where reasonable, and Owner accounts must never be locked out of all recovery paths. +- Add score-based temporary auto-bans with TTL for Visitor-ID and stable client-IP subjects. Trusted registered users are never auto-banned, and Owner accounts must never be locked out of all recovery paths. - Treat GeoIP as operational metadata for logs, statistics, and security review. Missing provider configuration must degrade gracefully. - Include IconCaptcha in the overall security feature cut, but keep its provider implementation in a dedicated branch after the generic captcha contract. - Cover adjacent security surfaces through the abuse/rate policy catalogue instead of local ad hoc checks: setup apply, CORS preflight, high-impact admin operations, package lifecycle, backup/restore, import/export, uploads/archives, diagnostic downloads, and support bundles. @@ -174,11 +174,11 @@ Detailed plan: [auto-ban](security-hardening/auto-ban.md). Scope: -- Add TTL-based ban records for IP, visitor ID, API key, and combined subjects. -- Add ban reasons, expiry, source signals, actor context, and audit entries. -- Apply softer thresholds or bypasses for authenticated users where appropriate. -- Enforce Owner safety so at least one active Owner retains a documented recovery path. -- Provide Admin review and manual unban tools. +- Score retained source-risk Security signals across a one-hour window, primarily by Visitor ID and secondarily by stable client-IP bucket/HMAC with a laxer threshold multiplier. +- Record low-weight Security signals for `400`, `403`, `404`, and `429` outcomes, correlating probe-plus-`400` evidence so one request is not double-counted and excluding login-required `401` by status alone. +- Store active TTL ban state through cache-flock-backed keys plus a cache-backed active-ban index, with escalation derived from retained ban-trigger `security_signal_event` records. +- Add required Config/Settings defaults for auto-ban enablement, trusted-user minimum access level, and score threshold through the settings/default provider so missing database states fail open. +- Add forced bare `403` ban responses, trusted-user/Owner/API-key recovery protection, active-ban review UI, detail pages backed by filtered Security signals, and Owner-gated manual reset. Non-goals: @@ -187,8 +187,8 @@ Non-goals: Acceptance: -- Clear bot/probe behavior can be temporarily blocked without blocking all Owner recovery. -- Operators can understand why a subject is blocked and when the block expires. +- Clear repeated suspicious behavior can be temporarily blocked without blocking trusted users or Owner recovery. +- Operators can understand why a subject is blocked from retained Security signals, see expiry, and reset the active ban immediately. ### `feat-security-captcha-contract` @@ -292,7 +292,7 @@ Acceptance: - Security identity must come from one reviewed resolver that uses Symfony's resolved request client IP. Security code must not trust raw `X-Forwarded-*` headers, ad-hoc IP parsing, package-owned client identity logic, or app-level trusted-proxy settings introduced by Security branches; trusted proxy handling belongs in deployment/webserver configuration. Visitor-ID generation may use raw forwarding-header values only as untrusted differentiation entropy and never as Security subject, GeoIP, ban, or signal evidence. - TTL, expiry, and cleanup behavior must use an injectable clock/time boundary so tests can cover expiry, replay, and cleanup deterministically. - Every enforcement branch must define its degraded-storage behavior explicitly. Optional observability features may fail open with redacted diagnostics; hard enforcement must avoid surprise Owner lockout and must audit degraded decisions. -- Race and idempotency behavior must be reviewed for one-shot captcha validation, limiter consumption/reset, auto-ban creation/manual unban, mail token delivery, and remember-me token rotation. +- Race and idempotency behavior must be reviewed for one-shot captcha validation, limiter consumption/reset, auto-ban creation/manual reset, mail token delivery, and remember-me token rotation. - Each PR must complete the Security PR-readiness checklist below from the actual branch diff. Do not pre-check items from the template without reviewing the changed public entry points, data flows, browser storage, package boundaries, docs, translations, and verification output for that branch. ## Security PR-readiness checklist @@ -310,21 +310,22 @@ Acceptance: ## Fixed implementation defaults -- Auto-ban storage uses database-backed TTL records plus cleanup. Cache may be added later as a speed layer, but the first reviewable implementation must keep Admin review and audit persistence understandable. -- Auto-ban enforcement applies by default to anonymous/IP/visitor/API probe abuse. Authenticated users start with softer handling such as throttling or captcha, and Owner accounts must retain recovery access. -- Auto-ban is enabled by default once implemented, but can be disabled through bounded Security settings. Visitor IDs and IP buckets tied to active Admin or Owner sessions must not be banned. -- IP-based enforcement is secondary, laxer than Visitor-ID enforcement, and short-lived. Prefer Visitor-ID-backed TTL bans for continuity, add IP TTL bans only to reduce cookie-reset bypasses, and keep every IP ban TTL below 30 days. +- Auto-ban active state uses cache-flock TTL keys. Admin review, escalation, and reset cutoffs are explained by retained `security_signal_event` records, including ban-trigger and reset signals, rather than a separate durable ban table. +- Auto-ban enforcement applies by default to Visitor-ID and IP source evidence. Trusted registered users at or above the configured trusted-user level, including valid API keys owned by trusted users, are never auto-banned, and Owner accounts must retain recovery access. +- Auto-ban is enabled by default once implemented, but can be disabled through bounded Security settings. Visitor IDs and IP buckets tied to trusted active sessions or trusted-user-owned API keys must not be banned. +- IP-based enforcement is secondary and laxer than Visitor-ID enforcement through a fixed `x2` threshold multiplier. Prefer Visitor-ID-backed scoring for continuity, evaluate IP scoring to reduce cookie/header-mutation bypasses, and keep every active ban TTL at or below 7 days. +- Scoreable request signals are persisted per evaluated source subject, normally Visitor ID and IP bucket, so scoring uses indexed subject reads rather than JSON-context filtering. Score aggregation is write-triggered after signal persistence and reuses that DB path; ordinary non-signal requests perform only the cheap active-ban cache check. If both Visitor and IP thresholds cross from one evaluation, create at most one active ban and prefer the Visitor ban. - Passive suspicious signals use database-backed short-lived records with redacted normalized subject keys, intent, reason code, weight/count, first/last seen timestamps, expiry, and safe context hash. They are not enforcement by themselves until the rate/ban branches consume them. - Security subject keys use normalized client identity, visitor ID, API key fingerprint/prefix, authenticated user UID, and safe combined keys produced by the shared resolver. Raw IP strings and raw credentials must not become cross-branch storage keys. - Raw IP addresses, IP buckets, and stable IP-derived hashes are queryable for at most 30 days across logs, projections, diagnostics, exports, and backups. Longer-term correlation uses visitor IDs, authenticated user IDs, API key fingerprints, or aggregate dimensions. - Turbo and browser prefetch are classified server-side and count at lower confidence. Disable prefetch only on expensive or side-effect-adjacent links. - Website global rate policy uses separate deliberate burst and sustained buckets so normal browsing is not measured by one oversized per-minute limit. Turbo/browser prefetch uses a separate lower-confidence observation path instead of spending the same budget as deliberate navigation. - Registered users receive higher ordinary navigation/API limits than anonymous visitors where the workflow has no explicit bucket. Owner-owned API keys and subjects tied to active Owner sessions are exempt from ordinary application rate-limit rejection. -- Suspicious probe paths are configurable with extensive defaults, return a generic `400`, and allow only one high-signal probe per subject per 10 minutes before draining suspicious buckets or feeding auto-ban decisions. -- A recovery login path such as `/user/login?bypass=1` must remain reachable even when the current Visitor ID or IP bucket is banned; it uses a dedicated small recovery bucket and successful credential login re-evaluates current bans and limiters under authenticated, Admin, or Owner policy. +- Suspicious probe paths are configurable with extensive defaults, return a generic `400`, and allow only one high-signal probe per subject per 10 minutes before draining suspicious buckets or feeding auto-ban decisions. Auto-ban must correlate the probe signal and its `400` response so one request is not double-counted. +- The `/{LANG}/users/login?bypass=1` recovery login render path, resolved through the shared `RequestPathResolver`, must remain reachable even when the current Visitor ID or IP bucket is banned; it uses a dedicated small recovery bucket and successful credential login re-evaluates current bans and limiters under authenticated, trusted-user, Admin, or Owner policy. - Successful login and verified provider-backed captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. - Captcha-based reset or `429` recovery requires an active provider-backed challenge. Provider `none`, missing-provider, or disabled-provider auto-success is never human proof and must not reset limits. -- The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and manual unban. +- The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and Owner-gated manual reset. - IconCaptcha challenge state uses a dedicated Symfony cache pool when practical, falling back to `cache.app` if no dedicated pool exists. The first provider default TTL is 15 minutes, with one-shot invalidation after every validation attempt and scoped failure buckets preventing brute-force guessing. - IconCaptcha accessibility must not reveal the visual answer through labels. If neutral labels are insufficient, use a provider-owned accessible quiz challenge with a spoken question/task and multiple answer options, generated and validated through the same one-shot challenge, TTL, and abuse-signal rules. - Account mail delivery uses provider-backed flow metadata, localized Markdown templates, Messenger queueing, and an initial transport guard of one queued message per account-flow action plus configurable worker-side retry/backoff. Debug action-link logging remains disabled outside explicit debug mode. @@ -333,7 +334,7 @@ Acceptance: ## Remaining calibration points - Define exact first thresholds while implementing `feat-security-abuse-foundation` and `feat-security-rate-enforcement`; record them as constants/config defaults and tests in those branches. -- Decide whether a future cache acceleration layer is needed after database-backed auto-ban behavior is measured. +- Verify the early auto-ban enforcement ordering carefully: active Visitor/IP bans must be resolved before error pages or rate-limit buckets can produce another response, but after authenticated trusted-user and trusted-user-owned API-key context is available to bypass those source bans. - Database-backed lookup projections for message, audit, access, and passive security signals are the preferred Admin/API read model for Security review and abuse correlation. File logs remain the durable raw source and operator fallback. - Define backup/export handling for short-retention security data before enabling any database-backed security projection, so restore and support workflows do not reintroduce expired IP-derived records. diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index a5da65c2..0e5a59dc 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -1,13 +1,13 @@ # Auto-ban branch plan > **Status**: Draft -> **Updated**: 2026-06-15 +> **Updated**: 2026-06-18 > **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. +Add score-based temporary bans for sustained suspicious behavior across browser, API, auth, probe, and error-response surfaces, while preserving trusted-user recovery access and keeping ban decisions explainable through retained Security signals. Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). @@ -20,80 +20,106 @@ 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. +- Existing Security signal storage, Admin log browsing, Admin ACL, user-role/access-level, visitor identity, client-IP identity, Config default provider, error rendering, and rate-limit 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. +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 score-based Security signals, cache-flock TTL enforcement, trusted-user recovery protection, and Admin review requirements have priority. Do not copy legacy logic, thresholds, persistence, or framework-specific shortcuts 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. -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. -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. +1. Add an `AutoBanScoreCatalogue` or similarly owned Security catalogue that assigns default score weights only to suspicious Security signal reasons. +2. Extend passive signal recording so relevant `400`, `403`, `404`, and `429` responses emit low-weight error-hit Security signals, with probe, auth/rate, copied-session or copied-visitor-cookie risk, invalid API/CORS probing, setup-apply abuse, upload/archive validation abuse, diagnostic/export probing, and other explicit high-confidence abuse signals carrying stronger reason-specific weights. Ordinary login-required `401` responses are the explicit non-suspicious auth boundary and do not feed auto-ban scoring by status alone. +3. Treat suspicious probe responses and their generic `400` status as one connected risk action. The probe signal is the high-confidence source; the response-status signal may add context but must not double-count the same request as two independent actions. +4. Add an auto-ban policy service that aggregates retained, non-reset Security signals over a one-hour scoring window by Visitor ID first and by stable client IP bucket/HMAC as a secondary source subject. Score aggregation runs only from the qualifying signal write path, reusing the active database connection after signal persistence; ordinary requests that do not create a scoreable signal must not perform database score lookups. +5. Ensure scoreable request signals are persisted for every evaluated source subject, normally Visitor ID and IP bucket, with shared request/correlation context so Visitor and IP scoring can use indexed `subject_type`/`subject_identifier` reads instead of portable-unsafe JSON filtering. +6. Add bounded Owner-gated Config/Settings defaults through the existing settings registry/default provider for auto-ban enablement, trusted-user minimum access level, score threshold, and any required bounded policy constants so missing databases use seeded defaults and do not cause Doctrine/DBAL throws during setup or degraded states. +7. Add cache-flock-backed active ban state with TTL plus a cache-backed active-ban index for Admin list rendering. The ban store and index must be fail-open when cache/lock storage is unavailable and must never create an invisible permanent block. +8. Emit a persistent `security_signal_event` record when a ban is triggered, including whether the effective subject was `visitor` or `ip`, the TTL/escalation context, score summary, and safe references needed for Admin review without exposing raw IPs, raw visitor-cookie tokens, headers, secrets, or raw credentials. +9. Emit a Security signal when an Owner manually resets a ban. Reset signals invalidate earlier retained signals for that same subject and subject type for future score and escalation calculations, so the visitor/IP starts at zero after reset. +10. Add enforcement early enough to run before controller/error-page rendering and before rate-limit buckets are consumed, but late enough that authenticated trusted users and trusted-user-owned API keys have been resolved and can bypass active Visitor/IP bans. Active temporary bans return the shared forced bare `403 Forbidden` response with `Retry-After`, generic message, and safe Request ID only. +11. Add Owner-gated Security settings UI fields for auto-ban enablement, trusted-user minimum access level, and score threshold. +12. Add the active-ban Admin list with subject type, safe subject label, created timestamp, TTL expiry, and detail link. +13. Add the ban detail page with filtered Security signals explaining the decision and an Owner-gated manual reset button. ## 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. -- 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 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. -- 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 Forbidden` with `Retry-After` when expiry is known, request-family-specific HTML/JSON bodies, redacted diagnostics, and `no-store`. -- Browser ban responses should use the shared `HttpErrorRenderer` forced bare response path by default. The bare context may include the generic text `Request blocked due to suspicious activity. retry-after: ` when a retry delay is known; responses must still include only the safe Request ID/reference and must not expose score values, rule names, subject keys, Visitor IDs, IP buckets, raw IP data, paths, headers, or signal internals. -- 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. +- Auto-ban is enabled by default through a bounded Security setting. +- Primary source scoring is by Visitor ID. Stable client IP evidence is evaluated separately to reduce header/cookie mutation bypasses, but IP-only thresholds use a fixed multiplier above the Visitor threshold so legitimate visitors behind NAT or untrusted proxies are less likely to be blocked. User accounts and API keys are context for trusted-user bypass decisions, not auto-ban subjects. +- The initial scoring window is one hour. +- First score defaults use a Visitor threshold of `100`, an IP threshold multiplier of `2` for an effective IP threshold of `200`, and a minimum of two qualifying signals before any ban can be created. +- Initial signal weights are: error-hit `7`, suspicious probe path `100`, copied session or copied visitor-cookie `100`, and failed authentication `10`. That means roughly 15 error hits, one high-confidence probe plus another qualifying signal, one session-copy signal plus another qualifying signal, or 10 failed auth attempts can reach the Visitor threshold inside the one-hour window. +- Triggered ban TTLs escalate globally as `1h`, `3h`, `24h`, and `7d`. +- Escalation is derived from retained prior ban-triggered `security_signal_event` records for the same subject type. Visitor and IP escalation counts are separate because ban-trigger signals record whether the ban was for Visitor ID or IP. +- When one incoming Security signal makes both Visitor and IP scores eligible, create at most one new active ban for that signal and prefer the Visitor ban. Create an IP ban only when the IP score crosses the laxer threshold and no Visitor ban is created for the same evaluation. This preserves the IP defense against cookie/header mutation without unnecessarily broadening NAT impact. +- Scoreable request signals should be recorded per evaluated source subject, not only as a primary subject with the IP bucket hidden in JSON context. The same request may therefore create paired Visitor/IP signal rows with a shared request ID or correlation context, while Admin detail views deduplicate them for human review. +- Score aggregation is write-triggered, not request-triggered. After a scoreable `security_signal_event` insert succeeds, the same DB connection may query retained rows for the affected Visitor/IP subjects using the existing `subject_type`, `subject_identifier`, and `occurred_at` index, apply the latest reset cutoff and one-hour window, and decide whether to write a ban-trigger signal plus cache-flock state. Requests with no new scoreable signal only perform the cheap active-ban cache check. +- Security-signal retention resets escalation naturally. Once prior ban-trigger signals expire or are reset, later bans start from the lower escalation tier again. +- Manual reset takes effect immediately by deleting/clearing active cache-flock ban state and recording a reset signal. Score and escalation queries ignore earlier signals at or before the latest reset signal for the same subject type/key. +- Threshold changes apply immediately for new decisions only. Existing active bans are not lifted automatically. If a subject is now above a lowered threshold but is not currently banned, the next qualifying Security signal triggers evaluation and may create the ban. This policy is intentional and should be preserved in reviews. +- Score thresholds and suspicious-action weights must be floored so at least one action always gets through and a ban cannot be created before the second qualifying signal for that subject type. +- The first implementation uses stable code defaults in a score catalogue, with settings only for enablement, trusted-user minimum access level, and score threshold. Per-signal weight tuning may become configurable later only at the catalogue boundary with tests and policy updates. +- The score is global per subject type/key, not separated into multiple buckets. Signal reasons decide weight; bucket family is diagnostic context only. +- Evaluated Security signals are limited to source-risk signals. Routine access logs, ordinary successful requests, expected validation failures, and login-required `401` responses do not contribute. Normal `404`, `403`, and `429` responses can still be weak signals because repeated misses, denials, or limiter collisions in a short window are source risk. +- Honeypot/probe signals may carry high scores because they represent high-confidence scanner behavior. Ordinary error-page and rate-limit hits must stay low enough that a single legitimate mistake is harmless while repeated hits can still cross the threshold. +- Trusted registered users are never auto-banned. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, the required setting also protects Owners from self-lockout through auto-ban. Valid API keys owned by trusted users inherit this bypass because the trusted user context has been resolved before active ban enforcement. +- The recovery login render path `/{LANG}/users/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. +- Ban decisions follow the Security policy enforcement order so trusted-user context, trusted-user API-key context, active Admin/Owner session context, and recovery-login rendering are resolved before Visitor/IP bans can deny access, while active bans still run before error pages or rate-limit responses can be produced. +- Config keys must be registered through the settings/default provider so setup, missing database, or unavailable database states read safe defaults without touching Doctrine/DBAL. When the database is unavailable, signal persistence and score evaluation cannot happen, so auto-ban degrades fail-open. +- Active temporary ban responses use the shared `HttpErrorRenderer` forced bare response path: `403 Forbidden`, `Retry-After` when TTL is known, `Cache-Control: no-store`, safe Request ID/reference, and a generic message. They must not expose score values, rule names, subject keys, Visitor IDs, IP buckets, raw IP data, paths, headers, signal internals, or ban history. +- Cache-flock state is the active enforcement state holder. Explainability and escalation come from retained `security_signal_event` records, including ban-trigger and reset records, not from a separate durable ban table. +- The active-ban list is backed by the active ban store's cache index, while detail/reason evidence is backed by retained Security signals. If the cache index is unavailable or inconsistent, enforcement must fail open and the Admin UI should show a safe degraded-state diagnostic rather than inferring active bans from stale historical signals alone. +- Ban-state keys come only from the shared subject/client-identity resolver and are limited to source subjects such as Visitor ID and stable IP bucket/HMAC. Raw IP strings, raw forwarding headers, raw API keys, credentials, usernames, emails, session IDs, visitor-cookie material, user IDs, and API-key identifiers must never become active auto-ban keys. +- IP-derived evaluation and Admin review remain within existing IP-retention ceilings. IP ban TTLs must not exceed the seven-day maximum TTL and must never extend queryable IP-derived evidence beyond retention. +- First implementation should use explicit subscriber priorities relative to existing security hooks: suspicious probe handling remains earliest, trusted-user/API-key context must be available before ordinary active-ban enforcement, and ordinary active-ban enforcement must run before `RateLimitRequestSubscriber::onKernelRequestOrdinary()` can consume buckets or return `429`. If a single priority cannot satisfy both browser-session and API-key context, split browser and API ban checks by request family while preserving this ordering. ## 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, 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. -- 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. +- Expired cache-flock bans must stop blocking even if cleanup is delayed. +- Missing or stale active-ban index entries must not create enforcement decisions by themselves; the authoritative active block is the per-subject cache-flock TTL state. +- Trusted users at or above the configured minimum access level must not be banned by Visitor ID or IP source scoring; valid API keys owned by trusted users must bypass existing Visitor/IP bans under the trusted-user rule. +- Owner sessions and Owner recovery must remain available even when the current Visitor ID or IP bucket is actively banned. +- Setup/install states may have no database and no Owner. Auto-ban must no-op/fail open in those states except for DB-free probe/error rendering already handled by earlier branches. +- Shared IPs can be blocked only after the laxer IP threshold is crossed and should not prevent trusted users from using the recovery/login path. +- Concurrent signal recording, score evaluation, ban creation, TTL expiry, and manual reset must be idempotent. A reset racing with ban creation must not leave a hidden active ban. +- Storage degradation in Security signal storage, cache, lock/flock, Config, or clock services must not hard-block the request or hide Owner recovery. +- If a scoreable signal is recorded but the subsequent score query or cache-flock ban creation fails, the request remains allowed or proceeds with the response already selected by the owning workflow. The persisted signal can still support later review, but auto-ban does not retry synchronously on unrelated requests. +- Status-code signals must be low-weight enough that ordinary content misses, permission denials, expected form validation, and occasional strict/panic `429` recovery paths do not create false positives, while repeated `400`/`403`/`404`/`429` hits in the scoring window still become source-risk evidence. +- Probe handling must not double-count one request as both an independent probe action and an independent `400` error action. +- Lowering the threshold must not retroactively unblock active bans; raising it must not retroactively erase retained evidence or escalation signals. ## Tests and validation -- 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 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 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. -- Test migration applies on SQLite. +- Test score aggregation over the one-hour window by Visitor ID and by IP subject. +- Test score floors so the first qualifying signal cannot trigger a ban and the second qualifying signal can trigger only when the configured threshold/weights justify it. +- Test suspicious probe plus `400` response correlation without double-counting one request. +- Test paired Visitor/IP source-signal persistence and Admin detail de-duplication so IP scoring does not depend on JSON-context filtering. +- Test that score aggregation runs only after scoreable signal writes and that ordinary non-signal requests perform no database score lookup beyond the active cache-ban check. +- Test that one evaluation creates at most one active ban and prefers Visitor over IP when both thresholds are crossed. +- Test `400`, `403`, `404`, and `429` Security-signal creation as low-weight source-risk signals, with login-required `401` excluded from auto-ban scoring by status alone. +- Test threshold changes: existing bans stay active, and newly over-threshold subjects are banned only after the next qualifying signal. +- Test TTL escalation `1h`, `3h`, `24h`, `7d` from retained ban-trigger Security signals and separate Visitor/IP escalation counts. +- Test retention expiry and manual reset signals invalidate earlier score/escalation evidence. +- Test active, expired, and manually reset cache-flock ban states. +- Test active-ban cache index list rendering, stale-index cleanup/degraded diagnostics, and that stale index entries do not block without active per-subject TTL state. +- Test fail-open behavior when database, Config, cache, lock/flock, or signal storage is unavailable. +- Test trusted registered users at and above the configured minimum access level are never auto-banned, with the default `MANAGER` level and Owner lockout protection covered. +- Test valid trusted-user-owned API keys bypass active Visitor/IP bans after API-key authentication resolves the trusted user context, while non-trusted or invalid API-key requests remain subject to Visitor/IP source enforcement. +- Test subscriber ordering against existing probe, API authentication, browser session, ordinary rate-limit, and error-rendering hooks. +- Test recovery-login bypass render despite active Visitor/IP bans, dedicated recovery-login bucket behavior, CSRF/credential/failure accounting, audit logging, and post-login re-evaluation. +- Test bare browser `403` response shape, `Retry-After`, request ID, `no-store`, and redaction. +- Test Admin active-ban list, detail filtering, and manual reset permissions/audit/signal creation. +- Test settings descriptors, default provider values, validation bounds, translations, and missing-database defaults. +- Test migration/schema only if this branch changes existing Security signal fields; the preferred implementation should avoid new ban tables. +- Test `php bin/console lint:container` after service/config changes. ## 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. +- Update Security policy defaults with final score weights, threshold default, multiplier, TTL escalation, trusted-user default, and response semantics. +- Update Security settings documentation/manual notes once the UI lands. +- Update Admin/security diagnostics notes for active-ban list, detail review, and manual reset semantics. +- Update class map for the score catalogue, policy service, cache-flock store, enforcement subscriber, settings descriptors, Admin routes/controllers, and tests. +- Record threshold and false-positive assumptions in the worklog. - Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. ## Non-goals @@ -101,9 +127,12 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - No permanent invisible deny list. - No GeoIP/country blocking. - No machine-learning risk scoring. +- No durable auto-ban table in the first implementation unless signal-store evidence proves cache-flock state is insufficient. +- No per-signal Admin weight editor in this branch. ## Acceptance criteria -- Clear bot/probe behavior can be blocked temporarily and reviewed. -- Operators can understand and reverse bans. -- Owner recovery remains available. +- Clear suspicious behavior is scored across relevant Security signals and temporarily blocked only after repeated evidence. +- Operators can understand active bans through filtered Security signals and can reset them immediately. +- Trusted users and Owner recovery remain available. +- Missing database or ban-store degradation fails open instead of creating lockout risk. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index ac8a5a8e..ccc621a9 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-17 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Define first implementation defaults for Security hardening branches before runtime work begins. @@ -33,8 +33,8 @@ The defaults are not an Admin UI requirement. Admin-configurable policy can be a - 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. +- IP-derived active auto-ban state: maximum 7 days, even though the privacy ceiling is 30 days. +- Visitor-ID active auto-ban state: maximum 7 days for the first score-based implementation. Escalation and review evidence come from retained `security_signal_event` records, including ban-trigger and reset records, not durable ban table rows. - 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: 15 minutes, one-shot invalidation after every validation attempt. - Remember-me trust window: seven days. @@ -46,10 +46,10 @@ Runtime enforcement must use one deterministic order so the same request is not 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 ordinary ban and rate checks so recovery protections and ordinary rate-limit exemptions can be evaluated safely. +3. Resolve active trusted-user, Admin/Owner, and valid API-key user context before ordinary ban and rate checks so recovery protections, trusted-user auto-ban bypasses, 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. +6. Check active Visitor/IP bans except where trusted-user protection, trusted-user-owned API-key protection, Admin/Owner protection, or the recovery-login rendering rule applies. This must happen before controllers, custom error-page rendering, and ordinary rate-limit bucket consumption can produce another response. 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. @@ -67,7 +67,7 @@ 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, 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. +- Admins may review ordinary anonymous/user abuse diagnostics where the Admin ACL surface allows it. The first auto-ban implementation keeps manual reset, disabling auto-ban, threshold changes, trusted-user-level changes, Owner/Admin subject relief, weakening recovery protections, and privacy-ceiling changes Owner-only; later ACL delegation may broaden ordinary anonymous/IP/Visitor reset only through an explicit policy update. - 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. 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. @@ -140,7 +140,7 @@ Multi-bucket requests must not partially spend earlier buckets when a later buck ## 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. +- Active temporary bans return the forced bare `403 Forbidden` response by default, with `Retry-After` when the ban expiry is known, the safe Request ID, `no-store`, and a generic message only. The response must not expose score values, raw reason internals, subject keys, Visitor IDs, IP buckets, IP data, paths, headers, signal internals, or bucket names. - 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. @@ -167,28 +167,33 @@ The codebase and other feature drafts expose several security-relevant surfaces ## 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; - - 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. -- 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; - - compromised or revoked-key replay review may escalate to 24 hours. -- 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. +- Auto-ban evaluates retained Security signals over a one-hour scoring window. The score is global per subject type/key, not split into separate buckets. +- Source-risk Security signals contribute to the score: repeated error responses, high-signal probes, failed auth/rate activity, invalid API/CORS probing, copied-session or copied-visitor-cookie risk, setup-apply abuse, upload/archive validation abuse, diagnostic/export probing, and similarly explicit abuse signals. Routine access, ordinary successful requests, expected validation failures, and login-required `401` responses do not contribute by status alone. +- `400`, `403`, `404`, and `429` responses emit low-weight source-risk signals by default. Probe responses already return `400`, so probe and response-status evidence for the same request must be correlated and not double-counted as independent actions. +- Visitor-ID source scoring is primary. Stable client-IP bucket/HMAC scoring is secondary to reduce header/cookie mutation bypasses. User accounts and API keys are trusted-context inputs, not active auto-ban subjects. +- IP-only scoring uses a fixed threshold multiplier above the Visitor-ID threshold so shared NAT and untrusted proxy users are not penalized as aggressively. The first default is `2x`. +- First score defaults use a Visitor threshold of `100`, an IP threshold multiplier of `2` for an effective IP threshold of `200`, and a minimum of two qualifying signals before any ban can be created. +- Initial signal weights are: error-hit `7`, suspicious probe path `100`, copied session or copied visitor-cookie `100`, and failed authentication `10`. These values let roughly 15 error hits, one high-confidence probe plus another qualifying signal, one session-copy signal plus another qualifying signal, or 10 failed auth attempts reach the Visitor threshold inside the one-hour window. +- Scoreable request signals should be persisted for every evaluated source subject, normally Visitor ID and IP bucket, using indexed `subject_type`/`subject_identifier` values and shared request/correlation context. IP scoring must not require filtering on JSON context fields. +- Score aggregation is write-triggered, not request-triggered. After a scoreable `security_signal_event` insert succeeds, the same DB connection may query retained rows for the affected Visitor/IP subjects using the indexed `subject_type`, `subject_identifier`, and `occurred_at` fields, apply the latest reset cutoff and one-hour window, and decide whether to write a ban-trigger signal plus cache-flock state. Requests with no new scoreable signal perform only the cheap active-ban cache check and must not start a database score lookup. +- When one incoming Security signal makes both Visitor and IP scores eligible, create at most one new active ban for that signal and prefer the Visitor ban. Create an IP ban only when the IP score crosses the laxer threshold and no Visitor ban is created for the same evaluation. +- Triggered active ban TTLs escalate as `1h`, `3h`, `24h`, and `7d` for both Visitor-ID and IP subjects. +- Escalation is derived from retained prior ban-trigger `security_signal_event` records for the same subject type/key. Ban-trigger signals must record whether the effective ban subject was `visitor` or `ip` so the escalation counters stay separate. +- Security-signal retention resets escalation naturally. Manual reset records a reset Security signal; score and escalation queries ignore earlier signals at or before the latest reset for the same subject type/key. +- Manual reset also clears the active cache-flock ban state immediately and must be audited. +- Threshold changes apply immediately for new ban decisions only. Existing active bans are not lifted automatically. If a subject is above a newly lowered threshold but is not yet banned, the next qualifying Security signal triggers re-evaluation and may create the ban. +- Score thresholds and suspicious-action weights must be floored so at least one action always gets through and a ban cannot be created before the second qualifying signal for that subject type. +- Initial score weights should live in a dedicated score catalogue similar to existing catalogue classes. Honeypot/probe and copied-session signals may have high weights, while ordinary error-hit and rate-limit-hit weights must be conservative enough that a single legitimate mistake is harmless and repeated hits can still become suspicious. +- Active ban state uses cache-flock TTL storage plus a cache-backed active-ban index for Admin list rendering. Explainability, escalation, and reset cutoffs come from retained `security_signal_event` records, including ban-trigger and reset records, rather than a separate durable ban table. The authoritative block is the per-subject cache-flock TTL state; stale index entries must not block. +- Auto-ban storage degradation is fail-open. If database, signal storage, Config, cache, lock/flock, 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. +- If a scoreable signal is recorded but the subsequent score query or cache-flock ban creation fails, the request remains allowed or proceeds with the response already selected by the owning workflow. Auto-ban must not retry score aggregation synchronously on later unrelated non-signal requests. +- Auto-ban Config keys must be registered through the settings/default provider so setup, missing database, or unavailable database states read safe defaults without touching Doctrine/DBAL. When the database is unavailable, signal persistence and score evaluation cannot happen, so the policy is fail-open. +- Trusted registered users are never auto-banned. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, this also protects Owners from self-lockout through auto-ban. Valid API keys owned by trusted users inherit this bypass because the trusted user context has been resolved before active ban enforcement. +- Visitor IDs and IP buckets that resolve to a trusted registered user session or trusted-user-owned API key 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 `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. +- Provide the recovery login render path `GET /{LANG}/users/login?bypass=1`, resolved through the shared `RequestPathResolver`, so the normal login form remains reachable even when the current Visitor ID or IP bucket is banned or ordinary website buckets are exhausted. The bypass flag only bypasses ban/rate checks that would prevent rendering the login form; it does not bypass CSRF, credential validation, login-failure accounting, the dedicated recovery-login bucket, audit logging, or post-login policy re-evaluation. Unsafe login submissions with `bypass=1` remain normal login attempts. - The dedicated recovery-login bucket is intentionally small but not lockout-like: 2 recovery-login requests per minute, 10 per hour, and a 30-minute retry window after exhaustion. -- Manual unban takes effect immediately and must be audited. ## Captcha Defaults @@ -235,8 +240,10 @@ These are first soft decisions for which values should stay fixed, become protec | 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 | +| Auto-ban enabled flag | Owner-gated Security setting default `on`, seeded through Security settings defaults | Yes, bounded | Disabling requires diagnostics; cannot disable trusted-user/Owner recovery, audit, or passive signal recording by accident | +| Auto-ban trusted-user minimum level | Owner-gated required Security setting default `6`/`MANAGER` | Yes, bounded | Cannot be empty; Owners are level `9` and remain protected from auto-ban self-lockout | +| Auto-ban score threshold | Owner-gated required Security setting default `100` plus score-catalogue weights | Yes, bounded | Threshold changes affect only new ban decisions; active bans are not auto-lifted; floors must allow at least one action and ban no earlier than the second qualifying signal | +| Auto-ban TTLs, scoring window, IP multiplier, and escalation | Code/config defaults in an auto-ban score/policy catalogue | Possibly later at catalogue boundary | One-hour score window; TTL escalation `1h`, `3h`, `24h`, `7d`; IP threshold defaults to Visitor threshold `x2`; no permanent bans; active state uses cache-flock TTL | | 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 | diff --git a/dev/draft/security-hardening/policy-docs.md b/dev/draft/security-hardening/policy-docs.md index 09a722a7..b2b0186a 100644 --- a/dev/draft/security-hardening/policy-docs.md +++ b/dev/draft/security-hardening/policy-docs.md @@ -25,14 +25,14 @@ 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. 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. +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, score-based cache-flock 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, minimal remember-me token management UI, and privacy-first IP retention ceilings. +- Documentation establishes fixed defaults for later branches: database-backed passive-signal records, score-based cache-flock auto-ban TTL state, 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 through a dedicated Admin ACL enforcement branch, uploads/archives, exports/downloads, diagnostic bundles, trusted-proxy identity, browser storage, and deferred HTTP security-header policy. diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 1d7b16ff..6451e07b 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-17 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Define the `feat-security-rate-enforcement` implementation plan. @@ -55,7 +55,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. +- Recovery login bypass is the resolver-matched `/{LANG}/users/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. @@ -75,7 +75,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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. +- Unsafe login submissions consume the login workflow bucket through the authentication-failure event when credentials fail, including manual bypass-query login posts; 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: resolver-matched `/{LANG}/users/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`. @@ -122,7 +122,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Non-goals -- No auto-ban records. +- No auto-ban state or enforcement. - No partial refund API. - No IconCaptcha provider. From 48ece22f51049f8bf8425d0c7e8091789033969b Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 13:08:57 +0200 Subject: [PATCH 03/55] Add auto-ban signal enforcement core --- config/services.yaml | 13 + .../Abuse/PassiveAbuseSignalSubscriber.php | 110 ++++-- src/Security/Abuse/SecuritySignalRecorder.php | 9 +- src/Security/AutoBan/ActiveAutoBan.php | 167 +++++++++ src/Security/AutoBan/AutoBanPolicy.php | 69 ++++ .../AutoBan/AutoBanRequestSubscriber.php | 162 +++++++++ .../AutoBan/AutoBanScoreCatalogue.php | 46 +++ .../AutoBan/AutoBanSignalEvaluator.php | 328 ++++++++++++++++++ src/Security/AutoBan/AutoBanStore.php | 243 +++++++++++++ src/Security/AutoBan/AutoBanSubject.php | 55 +++ .../RateLimitAuthenticationSubscriber.php | 56 +++ src/Security/SecurityMessageCode.php | 5 + src/Security/SecurityMessageKey.php | 5 + .../SessionVisitorBindingSubscriber.php | 41 ++- tests/Core/Message/MessageCodeTest.php | 4 +- tests/Core/Message/MessageKeyTest.php | 4 +- .../PassiveAbuseSignalSubscriberTest.php | 36 ++ .../AutoBan/AutoBanRequestSubscriberTest.php | 155 +++++++++ .../AutoBan/AutoBanSignalEvaluatorTest.php | 215 ++++++++++++ .../RateLimit/RateLimitEnforcerTest.php | 6 + translations/languages/de/message.yaml | 6 + translations/languages/en/message.yaml | 6 + 22 files changed, 1705 insertions(+), 36 deletions(-) create mode 100644 src/Security/AutoBan/ActiveAutoBan.php create mode 100644 src/Security/AutoBan/AutoBanPolicy.php create mode 100644 src/Security/AutoBan/AutoBanRequestSubscriber.php create mode 100644 src/Security/AutoBan/AutoBanScoreCatalogue.php create mode 100644 src/Security/AutoBan/AutoBanSignalEvaluator.php create mode 100644 src/Security/AutoBan/AutoBanStore.php create mode 100644 src/Security/AutoBan/AutoBanSubject.php create mode 100644 tests/Security/AutoBan/AutoBanRequestSubscriberTest.php create mode 100644 tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php diff --git a/config/services.yaml b/config/services.yaml index 44dc979e..9516bfa3 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -269,6 +269,19 @@ services: $cachePool: '@cache.rate_limiter' $lockFactory: '@lock.factory' + App\Security\AutoBan\AutoBanStore: + arguments: + $cache: '@cache.app' + $lockFactory: '@lock.factory' + + App\Security\AutoBan\AutoBanSignalEvaluator: + arguments: + $environment: '%kernel.environment%' + + App\Security\AutoBan\AutoBanRequestSubscriber: + arguments: + $environment: '%kernel.environment%' + App\Security\RateLimit\RateLimitRequestSubscriber: arguments: $environment: '%kernel.environment%' diff --git a/src/Security/Abuse/PassiveAbuseSignalSubscriber.php b/src/Security/Abuse/PassiveAbuseSignalSubscriber.php index 05de629c..0d7a6586 100644 --- a/src/Security/Abuse/PassiveAbuseSignalSubscriber.php +++ b/src/Security/Abuse/PassiveAbuseSignalSubscriber.php @@ -4,7 +4,10 @@ namespace App\Security\Abuse; +use App\Core\Access\AccessLevel; use App\Core\Log\AccessRequestMetadata; +use App\Security\AutoBan\AutoBanPolicy; +use App\Security\AutoBan\AutoBanRequestSubscriber; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; @@ -16,6 +19,7 @@ public function __construct( private AbuseRequestInspector $inspector, private SecuritySignalRecorder $signalRecorder, private AccessRequestMetadata $accessRequestMetadata, + private ?AutoBanPolicy $autoBanPolicy = null, ) { } @@ -28,59 +32,62 @@ public static function getSubscribedEvents(): array public function onKernelResponse(ResponseEvent $event): void { - if (!$event->isMainRequest() || $this->shouldSkip($event->getRequest()->getPathInfo())) { + if (!$event->isMainRequest() || $event->getRequest()->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE) || $this->shouldSkip($event->getRequest()->getPathInfo())) { return; } try { $inspection = $this->inspector->inspect($event->getRequest()); $profile = $inspection['profile']; - $signal = $this->signalFor($profile); + $signal = $this->signalFor($profile, $event->getResponse()->getStatusCode()); if (null === $signal) { return; } $subjects = $inspection['subjects']; - $subject = $subjects->primary(); - if (null === $subject) { + $sourceSubjects = $this->sourceSubjects($subjects, $signal['source_scored']); + if ([] === $sourceSubjects) { 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: $this->accessRequestMetadata->sanitizedPath($event->getRequest()), - route: $profile->route(), - httpStatus: $event->getResponse()->getStatusCode(), - context: [ - 'ip_bucket' => $ipBucket?->identifier(), - 'cost_bucket' => $cost->bucketFamily(), - 'cost_credits' => $cost->credits(), - 'ordinary_enforcement' => $cost->ordinaryEnforcement(), - ], - ); + + foreach ($sourceSubjects as $subject) { + $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: $this->accessRequestMetadata->sanitizedPath($event->getRequest()), + 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 + * @return array{type: string, reason: string, severity: string, confidence: int, source_scored: bool}|null */ - private function signalFor(AbuseRequestProfile $profile): ?array + private function signalFor(AbuseRequestProfile $profile, int $statusCode): ?array { if ($profile->suspiciousProbe()) { return [ @@ -88,6 +95,7 @@ private function signalFor(AbuseRequestProfile $profile): ?array 'reason' => 'security.signal.suspicious_probe', 'severity' => 'WARNING', 'confidence' => 95, + 'source_scored' => true, ]; } @@ -97,12 +105,58 @@ private function signalFor(AbuseRequestProfile $profile): ?array 'reason' => 'security.signal.prefetch_unsafe_method', 'severity' => 'NOTICE', 'confidence' => 60, + 'source_scored' => false, + ]; + } + + if (in_array($statusCode, [400, 403, 404, 429], true)) { + return [ + 'type' => 'http_error', + 'reason' => 'security.signal.error_http_status', + 'severity' => 'NOTICE', + 'confidence' => 40, + 'source_scored' => true, ]; } return null; } + /** + * @return list + */ + private function sourceSubjects(AbuseSubjectResolution $subjects, bool $sourceScored): array + { + if (!$sourceScored) { + $primary = $subjects->primary(); + + return null === $primary ? [] : [$primary]; + } + + if ($this->trustedContext($subjects)) { + $user = $subjects->first(AbuseSubjectType::User); + + return null === $user ? [] : [$user]; + } + + return array_values(array_filter([ + $subjects->first(AbuseSubjectType::Visitor), + $subjects->first(AbuseSubjectType::IpBucket), + ])); + } + + private function trustedContext(AbuseSubjectResolution $subjects): bool + { + $user = $subjects->first(AbuseSubjectType::User); + if (!$user instanceof AbuseSubject) { + return false; + } + + $level = $user->context()['access_level'] ?? AccessLevel::PUBLIC; + + return is_numeric($level) && (int) $level >= ($this->autoBanPolicy?->trustedAccessLevel() ?? AccessLevel::MANAGER); + } + private function shouldSkip(string $path): bool { return str_starts_with($path, '/_profiler') diff --git a/src/Security/Abuse/SecuritySignalRecorder.php b/src/Security/Abuse/SecuritySignalRecorder.php index 1a5aca17..4e1d18ee 100644 --- a/src/Security/Abuse/SecuritySignalRecorder.php +++ b/src/Security/Abuse/SecuritySignalRecorder.php @@ -7,6 +7,7 @@ use App\Core\Id\UuidFactory; use App\Core\Log\DatabaseLogRetentionPolicy; use App\Database\DatabaseReadyState; +use App\Security\AutoBan\AutoBanSignalEvaluator; use DateInterval; use Doctrine\DBAL\Connection; use Symfony\Component\Clock\ClockInterface; @@ -24,6 +25,7 @@ public function __construct( private ?DatabaseReadyState $databaseReadyState = null, private UuidFactory $uuidFactory = new UuidFactory(), private ClockInterface $clock = new NativeClock(), + private ?AutoBanSignalEvaluator $autoBanSignals = null, ) { } @@ -65,7 +67,7 @@ public function record( ]; try { - $this->connection->insert(self::TABLE, [ + $row = [ 'uid' => $this->uuidFactory->generate(), 'occurred_at' => $now->format('Y-m-d H:i:s'), 'expires_at' => $expiresAt->format('Y-m-d H:i:s'), @@ -84,8 +86,11 @@ public function record( 'route' => $this->short($route, 190), 'http_status' => $httpStatus, 'context' => $this->json($context), - ]); + ]; + + $this->connection->insert(self::TABLE, $row); $this->purgeExpired(); + $this->autoBanSignals?->afterSignalRecorded([...$row, ...$context]); } catch (Throwable) { return; } diff --git a/src/Security/AutoBan/ActiveAutoBan.php b/src/Security/AutoBan/ActiveAutoBan.php new file mode 100644 index 00000000..da680c37 --- /dev/null +++ b/src/Security/AutoBan/ActiveAutoBan.php @@ -0,0 +1,167 @@ + $context + */ + public function __construct( + private string $key, + private string $subjectType, + private string $subjectIdentifier, + private DateTimeImmutable $createdAt, + private DateTimeImmutable $expiresAt, + private int $ttlSeconds, + private array $context = [], + ) { + } + + public function key(): string + { + return $this->key; + } + + public function subjectType(): string + { + return $this->subjectType; + } + + public function subjectIdentifier(): string + { + return $this->subjectIdentifier; + } + + public function createdAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function expiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function ttlSeconds(): int + { + return $this->ttlSeconds; + } + + public function retryAfterSeconds(DateTimeImmutable $now): int + { + return max(1, $this->expiresAt->getTimestamp() - $now->getTimestamp()); + } + + /** + * @return array + */ + public function context(): array + { + return $this->context; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'key' => $this->key, + 'subject_type' => $this->subjectType, + 'subject_identifier' => $this->subjectIdentifier, + 'subject_label' => $this->subjectLabel(), + 'created_at' => $this->createdAt->format('Y-m-d H:i:s'), + 'expires_at' => $this->expiresAt->format('Y-m-d H:i:s'), + 'ttl_seconds' => $this->ttlSeconds, + 'context' => $this->context, + ]; + } + + public function subjectLabel(): string + { + return $this->subjectType.':'.substr($this->subjectIdentifier, 0, 12); + } + + /** + * @param array $payload + */ + public static function fromArray(array $payload): ?self + { + foreach (['key', 'subject_type', 'subject_identifier', 'created_at', 'expires_at', 'ttl_seconds'] as $field) { + if (!isset($payload[$field])) { + return null; + } + } + + $createdAt = self::timestamp($payload['created_at']); + $expiresAt = self::timestamp($payload['expires_at']); + if (null === $createdAt || null === $expiresAt) { + return null; + } + + $key = self::text($payload['key']); + $subjectType = self::text($payload['subject_type']); + $subjectIdentifier = self::text($payload['subject_identifier']); + $ttlSeconds = self::positiveInteger($payload['ttl_seconds']); + if (null === $key || null === $subjectType || null === $subjectIdentifier || null === $ttlSeconds) { + return null; + } + + return new self( + $key, + $subjectType, + $subjectIdentifier, + $createdAt, + $expiresAt, + $ttlSeconds, + is_array($payload['context'] ?? null) ? $payload['context'] : [], + ); + } + + private static function timestamp(mixed $value): ?DateTimeImmutable + { + if (!is_scalar($value)) { + return null; + } + + $value = trim((string) $value); + if ('' === $value || str_contains($value, "\0")) { + return null; + } + + $parsed = DateTimeImmutable::createFromFormat('!'.self::TIMESTAMP_FORMAT, $value); + $errors = DateTimeImmutable::getLastErrors(); + + return $parsed instanceof DateTimeImmutable + && (false === $errors || (0 === $errors['warning_count'] && 0 === $errors['error_count'])) + ? $parsed + : null; + } + + private static function text(mixed $value): ?string + { + if (!is_scalar($value)) { + return null; + } + + $value = trim((string) $value); + + return '' === $value || str_contains($value, "\0") ? null : $value; + } + + private static function positiveInteger(mixed $value): ?int + { + if (!is_int($value) && !(is_string($value) && ctype_digit($value))) { + return null; + } + + return max(1, (int) $value); + } +} diff --git a/src/Security/AutoBan/AutoBanPolicy.php b/src/Security/AutoBan/AutoBanPolicy.php new file mode 100644 index 00000000..abfc49b8 --- /dev/null +++ b/src/Security/AutoBan/AutoBanPolicy.php @@ -0,0 +1,69 @@ + */ + public const TTL_ESCALATION_SECONDS = [3600, 10800, 86400, 604800]; + + public function __construct(private Config $config) + { + } + + public function enabled(): bool + { + return true === $this->config->get(self::ENABLED_KEY, self::DEFAULT_ENABLED); + } + + public function newBanOwnerAlertsEnabled(): bool + { + return true === $this->config->get(self::NEW_BAN_OWNER_ALERTS_KEY, self::DEFAULT_NEW_BAN_OWNER_ALERTS); + } + + public function trustedAccessLevel(): int + { + $level = $this->config->get(self::TRUSTED_ACCESS_LEVEL_KEY, self::DEFAULT_TRUSTED_ACCESS_LEVEL); + + return AccessLevel::assert(is_numeric($level) ? (int) $level : self::DEFAULT_TRUSTED_ACCESS_LEVEL) ?? self::DEFAULT_TRUSTED_ACCESS_LEVEL; + } + + public function visitorThreshold(): int + { + $threshold = $this->config->get(self::SCORE_THRESHOLD_KEY, self::DEFAULT_SCORE_THRESHOLD); + + return max(2, is_numeric($threshold) ? (int) $threshold : self::DEFAULT_SCORE_THRESHOLD); + } + + public function thresholdFor(string $subjectType): int + { + $threshold = $this->visitorThreshold(); + + return AutoBanSubject::IP === $subjectType ? $threshold * self::IP_THRESHOLD_MULTIPLIER : $threshold; + } + + public function ttlForEscalationCount(int $priorBanSignals): int + { + $index = max(0, min(count(self::TTL_ESCALATION_SECONDS) - 1, $priorBanSignals)); + + return self::TTL_ESCALATION_SECONDS[$index]; + } +} diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php new file mode 100644 index 00000000..45f3d41d --- /dev/null +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -0,0 +1,162 @@ +paths = $paths ?? new PathScopeMatcher(); + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => ['onKernelRequest', 4], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest() || $event->hasResponse() || !$this->enabledForRequest($event->getRequest()) || !$this->policy->enabled()) { + return; + } + + $request = $event->getRequest(); + if ($this->excludedRequest($request)) { + return; + } + + try { + $inspection = $this->inspector->inspect($request); + if (RequestIntent::RecoveryLogin === $inspection['profile']->intent() || $this->trustedContext($inspection['subjects']->subjects())) { + return; + } + + foreach ([AbuseSubjectType::Visitor, AbuseSubjectType::IpBucket] as $type) { + $subject = $inspection['subjects']->first($type); + if (!$subject instanceof AbuseSubject) { + continue; + } + + $autoBanSubject = AutoBanSubject::fromAbuseSubject($subject); + if (!$autoBanSubject instanceof AutoBanSubject) { + continue; + } + + $ban = $this->store->active($autoBanSubject); + if (!$ban instanceof ActiveAutoBan) { + continue; + } + + $event->setResponse($this->banResponse($request, $ban)); + $request->attributes->set(self::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + + return; + } + } catch (Throwable $error) { + $this->reportEvaluation($error, [ + 'operation' => 'request_enforcement', + 'path' => $request->getPathInfo(), + ]); + + return; + } + } + + /** + * @param list $subjects + */ + private function trustedContext(array $subjects): bool + { + foreach ($subjects as $subject) { + if (AbuseSubjectType::User !== $subject->type()) { + continue; + } + + $level = $subject->context()['access_level'] ?? AccessLevel::PUBLIC; + + return is_numeric($level) && (int) $level >= $this->policy->trustedAccessLevel(); + } + + return false; + } + + private function banResponse(Request $request, ActiveAutoBan $ban): Response + { + $retryAfter = $ban->retryAfterSeconds($this->clock->now()); + + return $this->httpError->bare(Response::HTTP_FORBIDDEN, $request, [ + 'request_id' => $this->requestMetadata->requestId($request), + 'bare_context' => 'Request blocked due to suspicious activity. retry-after: '.$retryAfter, + ], [ + 'Retry-After' => (string) $retryAfter, + ]); + } + + private function excludedRequest(Request $request): bool + { + return $this->paths->matchesAnyPrefix($request->getPathInfo(), '/api/live', '/assets', '/build', '/_profiler', '/_wdt') + || in_array($request->getPathInfo(), ['/favicon.ico', '/robots.txt'], true); + } + + private function enabledForRequest(Request $request): bool + { + return 'test' !== $this->environment || '1' === $request->headers->get('X-Auto-Ban-Testing'); + } + + /** + * @param array $context + */ + private function reportEvaluation(Throwable $error, array $context = []): void + { + try { + $this->messageReporter?->report(Message::exception( + SecurityMessageCode::AUTO_BAN_EVALUATION_DEGRADED, + SecurityMessageKey::AUTO_BAN_EVALUATION_DEGRADED, + context: [ + 'exception' => $error::class, + 'message' => $error->getMessage(), + ...$context, + ], + ), ['component' => self::class]); + } catch (Throwable) { + } + } +} diff --git a/src/Security/AutoBan/AutoBanScoreCatalogue.php b/src/Security/AutoBan/AutoBanScoreCatalogue.php new file mode 100644 index 00000000..b6229983 --- /dev/null +++ b/src/Security/AutoBan/AutoBanScoreCatalogue.php @@ -0,0 +1,46 @@ + $signal + */ + public function afterSignalRecorded(array $signal): void + { + try { + if (!$this->enabled() || !$this->policy->enabled() || (null !== $this->databaseReadyState && !$this->databaseReadyState->isReady())) { + return; + } + + if (true === ($signal['auto_ban_exempt'] ?? false)) { + return; + } + + $subjectType = (string) ($signal['subject_type'] ?? ''); + $subjectIdentifier = (string) ($signal['subject_identifier'] ?? ''); + if (!$this->scores->scoreableSubject($subjectType) || '' === $subjectIdentifier) { + return; + } + + $score = $this->scores->scoreFor( + (string) ($signal['signal_type'] ?? ''), + (string) ($signal['reason_code'] ?? ''), + isset($signal['http_status']) ? (int) $signal['http_status'] : null, + ); + + if ($score <= 0) { + return; + } + + $subject = new AutoBanSubject($subjectType, $subjectIdentifier, AutoBanSubject::IP === $subjectType); + if (AutoBanSubject::IP === $subjectType && $this->banTriggeredForRequest((string) ($signal['request_id'] ?? ''))) { + return; + } + + if (null !== $this->store->active($subject)) { + return; + } + + $summary = $this->scoreSummary($subject); + if ($summary['signal_count'] < AutoBanPolicy::MINIMUM_QUALIFYING_SIGNALS || $summary['score'] < $this->policy->thresholdFor($subjectType)) { + return; + } + + $priorBanSignals = $this->priorBanSignalCount($subject); + $ttlSeconds = $this->policy->ttlForEscalationCount($priorBanSignals); + $ban = $this->store->ban($subject, $ttlSeconds, [ + 'score' => $summary['score'], + 'signal_count' => $summary['signal_count'], + 'threshold' => $this->policy->thresholdFor($subjectType), + 'window_seconds' => AutoBanPolicy::SCORE_WINDOW_SECONDS, + 'escalation_index' => $priorBanSignals, + ]); + + if (null === $ban) { + return; + } + + if (!$this->recordBanTriggeredSignal($subject, $ban, $summary, $signal, $priorBanSignals)) { + $this->store->reset($ban->key()); + + return; + } + + $this->ownerAlerts?->notifyBanTriggered($ban); + } catch (Throwable $error) { + $this->reportEvaluation('after_signal_recorded', $error, [ + 'reason_code' => (string) ($signal['reason_code'] ?? 'n/a'), + 'subject_type' => (string) ($signal['subject_type'] ?? 'n/a'), + ]); + + return; + } + } + + private function enabled(): bool + { + return 'test' !== $this->environment; + } + + /** + * @return array{score: int, signal_count: int} + */ + public function scoreSummary(AutoBanSubject $subject): array + { + $now = $this->clock->now(); + $windowStart = $now->modify('-'.AutoBanPolicy::SCORE_WINDOW_SECONDS.' seconds'); + $resetAt = $this->latestResetAt($subject); + if ($resetAt instanceof DateTimeImmutable && $resetAt > $windowStart) { + $windowStart = $resetAt; + } + + $rows = $this->connection->fetchAllAssociative( + 'SELECT signal_type, reason_code, http_status FROM '.self::TABLE.' WHERE subject_type = ? AND subject_identifier = ? AND occurred_at > ? AND occurred_at <= ? AND expires_at > ? ORDER BY occurred_at ASC', + [ + $subject->type(), + $subject->identifier(), + $windowStart->format('Y-m-d H:i:s'), + $now->format('Y-m-d H:i:s'), + $now->format('Y-m-d H:i:s'), + ], + ); + + $score = 0; + $count = 0; + foreach ($rows as $row) { + $weight = $this->scores->scoreFor( + (string) ($row['signal_type'] ?? ''), + (string) ($row['reason_code'] ?? ''), + null === ($row['http_status'] ?? null) ? null : (int) $row['http_status'], + ); + if ($weight <= 0) { + continue; + } + + $score += $weight; + ++$count; + } + + return ['score' => $score, 'signal_count' => $count]; + } + + private function priorBanSignalCount(AutoBanSubject $subject): int + { + $resetAt = $this->latestResetAt($subject); + $parameters = [$subject->type(), $subject->identifier(), AutoBanScoreCatalogue::SIGNAL_TRIGGERED, $this->clock->now()->format('Y-m-d H:i:s')]; + $where = 'subject_type = ? AND subject_identifier = ? AND reason_code = ? AND expires_at > ?'; + + if ($resetAt instanceof DateTimeImmutable) { + $where .= ' AND occurred_at > ?'; + $parameters[] = $resetAt->format('Y-m-d H:i:s'); + } + + return (int) $this->connection->fetchOne('SELECT COUNT(*) FROM '.self::TABLE.' WHERE '.$where, $parameters); + } + + private function banTriggeredForRequest(string $requestId): bool + { + if ('' === $requestId || 'n/a' === $requestId) { + return false; + } + + return (int) $this->connection->fetchOne( + 'SELECT COUNT(*) FROM '.self::TABLE.' WHERE request_id = ? AND reason_code = ?', + [$requestId, AutoBanScoreCatalogue::SIGNAL_TRIGGERED], + ) > 0; + } + + private function latestResetAt(AutoBanSubject $subject): ?DateTimeImmutable + { + $value = $this->connection->fetchOne( + 'SELECT occurred_at FROM '.self::TABLE.' WHERE subject_type = ? AND subject_identifier = ? AND reason_code = ? ORDER BY occurred_at DESC LIMIT 1', + [$subject->type(), $subject->identifier(), AutoBanScoreCatalogue::SIGNAL_RESET], + ); + + if (!is_string($value) || '' === $value) { + return null; + } + + $parsed = $this->timestamp($value); + if (null === $parsed) { + $this->reportInvalidPayload([ + 'payload' => 'reset_signal_timestamp', + 'subject_type' => $subject->type(), + ]); + + return null; + } + + return $parsed; + } + + /** + * @param array{score: int, signal_count: int} $summary + * @param array $sourceSignal + */ + private function recordBanTriggeredSignal( + AutoBanSubject $subject, + ActiveAutoBan $ban, + array $summary, + array $sourceSignal, + int $priorBanSignals, + ): bool { + $now = $this->clock->now(); + $expiresAt = $now->add(new DateInterval('P'.$this->retentionPolicy->retentionDaysForSignal().'D')); + $context = [ + 'effective_subject_type' => $subject->effectiveType(), + 'active_ban_key' => $ban->key(), + 'score' => $summary['score'], + 'signal_count' => $summary['signal_count'], + 'threshold' => $this->policy->thresholdFor($subject->type()), + 'ttl_seconds' => $ban->ttlSeconds(), + 'expires_at' => $ban->expiresAt()->format('Y-m-d H:i:s'), + 'escalation_index' => $priorBanSignals, + 'source_request_id' => $sourceSignal['request_id'] ?? 'n/a', + 'source_reason_code' => $sourceSignal['reason_code'] ?? 'n/a', + ]; + + 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' => 'auto_ban', + 'reason_code' => AutoBanScoreCatalogue::SIGNAL_TRIGGERED, + 'severity' => 'WARNING', + 'confidence' => 100, + 'subject_type' => $subject->type(), + 'subject_identifier' => $subject->identifier(), + 'ip_derived' => $subject->ipDerived() ? 1 : 0, + 'request_family' => (string) ($sourceSignal['request_family'] ?? 'unknown'), + 'request_intent' => (string) ($sourceSignal['request_intent'] ?? 'unknown'), + 'request_id' => (string) ($sourceSignal['request_id'] ?? 'n/a'), + 'visitor_id' => (string) ($sourceSignal['visitor_id'] ?? 'n/a'), + 'path' => (string) ($sourceSignal['path'] ?? 'n/a'), + 'route' => (string) ($sourceSignal['route'] ?? 'n/a'), + 'http_status' => null, + 'context' => $this->json($context), + ]); + + return true; + } catch (Throwable $error) { + $this->reportEvaluation('record_ban_triggered_signal', $error, [ + 'active_ban_key' => $ban->key(), + 'subject_type' => $subject->type(), + ]); + + return false; + } + } + + private function json(mixed $value): string + { + $encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return is_string($encoded) ? $encoded : '{}'; + } + + private function timestamp(string $value): ?DateTimeImmutable + { + $value = trim($value); + if ('' === $value || str_contains($value, "\0")) { + return null; + } + + $parsed = DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value); + $errors = DateTimeImmutable::getLastErrors(); + + return $parsed instanceof DateTimeImmutable + && (false === $errors || (0 === $errors['warning_count'] && 0 === $errors['error_count'])) + ? $parsed + : null; + } + + /** + * @param array $context + */ + private function reportEvaluation(string $operation, Throwable $error, array $context = []): void + { + try { + $this->messageReporter?->report(Message::exception( + SecurityMessageCode::AUTO_BAN_EVALUATION_DEGRADED, + SecurityMessageKey::AUTO_BAN_EVALUATION_DEGRADED, + context: [ + 'operation' => $operation, + 'exception' => $error::class, + 'message' => $error->getMessage(), + ...$context, + ], + ), ['component' => self::class]); + } catch (Throwable) { + } + } + + /** + * @param array $context + */ + private function reportInvalidPayload(array $context): void + { + try { + $this->messageReporter?->report(Message::warning( + SecurityMessageCode::AUTO_BAN_PAYLOAD_INVALID, + SecurityMessageKey::AUTO_BAN_PAYLOAD_INVALID, + context: $context, + ), ['component' => self::class]); + } catch (Throwable) { + } + } +} diff --git a/src/Security/AutoBan/AutoBanStore.php b/src/Security/AutoBan/AutoBanStore.php new file mode 100644 index 00000000..7598a75f --- /dev/null +++ b/src/Security/AutoBan/AutoBanStore.php @@ -0,0 +1,243 @@ + $context + */ + public function ban(AutoBanSubject $subject, int $ttlSeconds, array $context = []): ?ActiveAutoBan + { + $lock = $this->lockFactory->createLock(self::KEY_PREFIX.$subject->key(), 5.0); + + try { + if (!$lock->acquire()) { + return $this->active($subject); + } + + if (null !== ($active = $this->active($subject))) { + return $active; + } + + $now = $this->clock->now(); + $ban = new ActiveAutoBan( + $subject->key(), + $subject->type(), + $subject->identifier(), + $now, + $now->modify('+'.$ttlSeconds.' seconds'), + $ttlSeconds, + $context, + ); + + $item = $this->cache->getItem($this->cacheKey($ban->key())); + $item->set($ban->toArray()); + $item->expiresAfter($ttlSeconds); + if (!$this->cache->save($item)) { + return null; + } + + $this->upsertIndex($ban); + + return $ban; + } catch (Throwable $error) { + $this->reportStorage('ban', $error, ['active_ban_key' => $subject->key()]); + + return null; + } finally { + try { + $lock->release(); + } catch (Throwable $error) { + $this->reportStorage('lock_release', $error, ['active_ban_key' => $subject->key()]); + } + } + } + + public function active(AutoBanSubject $subject): ?ActiveAutoBan + { + return $this->activeByKey($subject->key()); + } + + public function activeByKey(string $key): ?ActiveAutoBan + { + try { + $item = $this->cache->getItem($this->cacheKey($key)); + if (!$item->isHit()) { + return null; + } + + $payload = $item->get(); + $ban = ActiveAutoBan::fromArray(is_array($payload) ? $payload : []); + if (null === $ban || $ban->expiresAt() <= $this->clock->now()) { + if (null === $ban && is_array($payload)) { + $this->reportInvalidPayload($key, $payload); + } + $this->cache->deleteItem($this->cacheKey($key)); + + return null; + } + + return $ban; + } catch (Throwable $error) { + $this->reportStorage('active_lookup', $error, ['active_ban_key' => $key]); + + return null; + } + } + + public function reset(string $key): ?ActiveAutoBan + { + try { + $ban = $this->activeByKey($key); + $this->cache->deleteItem($this->cacheKey($key)); + $this->removeIndex($key); + + return $ban; + } catch (Throwable $error) { + $this->reportStorage('reset', $error, ['active_ban_key' => $key]); + + return null; + } + } + + /** + * @return list + */ + public function activeBans(): array + { + try { + $index = $this->index(); + $active = []; + + foreach (array_keys($index) as $key) { + $ban = $this->activeByKey((string) $key); + if (null === $ban) { + $this->removeIndex((string) $key); + continue; + } + + $active[] = $ban; + } + + usort( + $active, + static fn (ActiveAutoBan $left, ActiveAutoBan $right): int => $right->createdAt() <=> $left->createdAt(), + ); + + return $active; + } catch (Throwable $error) { + $this->reportStorage('active_list', $error); + + return []; + } + } + + private function cacheKey(string $key): string + { + return self::KEY_PREFIX.$key; + } + + private function upsertIndex(ActiveAutoBan $ban): void + { + $index = $this->index(); + $index[$ban->key()] = [ + 'subject_type' => $ban->subjectType(), + 'subject_label' => $ban->subjectLabel(), + 'expires_at' => $ban->expiresAt()->format('Y-m-d H:i:s'), + ]; + $this->saveIndex($index); + } + + private function removeIndex(string $key): void + { + $index = $this->index(); + unset($index[$key]); + $this->saveIndex($index); + } + + /** + * @return array + */ + private function index(): array + { + $item = $this->cache->getItem(self::INDEX_KEY); + + return $item->isHit() && is_array($item->get()) ? $item->get() : []; + } + + /** + * @param array $index + */ + private function saveIndex(array $index): void + { + $item = $this->cache->getItem(self::INDEX_KEY); + $item->set($index); + $item->expiresAfter(604800); + $this->cache->save($item); + } + + /** + * @param array $context + */ + private function reportStorage(string $operation, Throwable $error, array $context = []): void + { + try { + $this->messageReporter?->report(Message::exception( + SecurityMessageCode::AUTO_BAN_STORAGE_DEGRADED, + SecurityMessageKey::AUTO_BAN_STORAGE_DEGRADED, + context: [ + 'operation' => $operation, + 'exception' => $error::class, + 'message' => $error->getMessage(), + ...$context, + ], + ), ['component' => self::class]); + } catch (Throwable) { + } + } + + /** + * @param array $payload + */ + private function reportInvalidPayload(string $key, array $payload): void + { + try { + $this->messageReporter?->report(Message::warning( + SecurityMessageCode::AUTO_BAN_PAYLOAD_INVALID, + SecurityMessageKey::AUTO_BAN_PAYLOAD_INVALID, + context: [ + 'active_ban_key' => $key, + 'payload_fields' => array_values(array_filter( + array_keys($payload), + static fn (mixed $field): bool => is_string($field), + )), + ], + ), ['component' => self::class]); + } catch (Throwable) { + } + } +} diff --git a/src/Security/AutoBan/AutoBanSubject.php b/src/Security/AutoBan/AutoBanSubject.php new file mode 100644 index 00000000..4a8aaa39 --- /dev/null +++ b/src/Security/AutoBan/AutoBanSubject.php @@ -0,0 +1,55 @@ +type()) { + AbuseSubjectType::Visitor => new self(self::VISITOR, $subject->identifier(), false), + AbuseSubjectType::IpBucket => new self(self::IP, $subject->identifier(), true), + default => null, + }; + } + + public function __construct( + private string $type, + private string $identifier, + private bool $ipDerived = false, + ) { + } + + public function type(): string + { + return $this->type; + } + + public function identifier(): string + { + return $this->identifier; + } + + public function ipDerived(): bool + { + return $this->ipDerived; + } + + public function effectiveType(): string + { + return self::IP === $this->type ? 'ip' : 'visitor'; + } + + public function key(): string + { + return substr(hash('sha256', $this->type.'|'.$this->identifier), 0, 40); + } +} diff --git a/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php b/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php index 3b0b304a..54973bf8 100644 --- a/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php +++ b/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php @@ -4,9 +4,15 @@ namespace App\Security\RateLimit; +use App\Core\Log\AccessRequestMetadata; +use App\Security\Abuse\AbuseRequestInspector; +use App\Security\Abuse\AbuseSubject; +use App\Security\Abuse\AbuseSubjectType; +use App\Security\Abuse\SecuritySignalRecorder; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Symfony\Component\Security\Http\Event\LoginSuccessEvent; +use Throwable; final readonly class RateLimitAuthenticationSubscriber implements EventSubscriberInterface { @@ -15,6 +21,9 @@ public function __construct( private RateLimitEnforcer $enforcer, private RateLimitResponseRenderer $responses, private string $environment, + private ?SecuritySignalRecorder $signalRecorder = null, + private ?AbuseRequestInspector $inspector = null, + private ?AccessRequestMetadata $accessRequestMetadata = null, ) { } @@ -37,6 +46,8 @@ public function onLoginSuccess(LoginSuccessEvent $event): void public function onLoginFailure(LoginFailureEvent $event): void { $request = $event->getRequest(); + $this->recordAuthFailure($event); + if (!$this->enabledForRequest($request->headers->get('X-Rate-Limit-Testing'))) { return; } @@ -53,4 +64,49 @@ private function enabledForRequest(?string $testOptIn): bool { return 'test' !== $this->environment || '1' === $testOptIn; } + + private function recordAuthFailure(LoginFailureEvent $event): void + { + if (null === $this->signalRecorder || null === $this->inspector || null === $this->accessRequestMetadata) { + return; + } + + try { + $request = $event->getRequest(); + $inspection = $this->inspector->inspect($request); + $profile = $inspection['profile']; + $subjects = $inspection['subjects']; + $visitor = $subjects->first(AbuseSubjectType::Visitor); + $ipBucket = $subjects->first(AbuseSubjectType::IpBucket); + + foreach (array_filter([$visitor, $ipBucket]) as $subject) { + if (!$subject instanceof AbuseSubject) { + continue; + } + + $this->signalRecorder->record( + 'auth', + 'security.signal.auth_failure', + $subject->type()->value, + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: 'NOTICE', + confidence: 70, + requestFamily: $profile->family()->value, + requestIntent: $profile->intent()->value, + requestId: $this->accessRequestMetadata->requestId($request), + visitorId: $visitor?->identifier() ?? 'n/a', + path: $this->accessRequestMetadata->sanitizedPath($request), + route: $profile->route(), + httpStatus: null, + context: [ + 'ip_bucket' => $ipBucket?->identifier(), + 'authenticator' => $event->getFirewallName(), + ], + ); + } + } catch (Throwable) { + return; + } + } } diff --git a/src/Security/SecurityMessageCode.php b/src/Security/SecurityMessageCode.php index b413a0ea..70bff188 100644 --- a/src/Security/SecurityMessageCode.php +++ b/src/Security/SecurityMessageCode.php @@ -25,4 +25,9 @@ final class SecurityMessageCode 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'; + public const AUTO_BAN_STORAGE_DEGRADED = 'auto_ban.storage_degraded'; + public const AUTO_BAN_EVALUATION_DEGRADED = 'auto_ban.evaluation_degraded'; + public const AUTO_BAN_PAYLOAD_INVALID = 'auto_ban.payload_invalid'; + public const AUTO_BAN_RESET_RELEASED = 'auto_ban.reset_released'; + public const AUTO_BAN_ALERT_DELIVERY_DEGRADED = 'auto_ban.alert_delivery_degraded'; } diff --git a/src/Security/SecurityMessageKey.php b/src/Security/SecurityMessageKey.php index 0cbc8ff8..f0f09b83 100644 --- a/src/Security/SecurityMessageKey.php +++ b/src/Security/SecurityMessageKey.php @@ -43,4 +43,9 @@ final class SecurityMessageKey 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'; + public const AUTO_BAN_STORAGE_DEGRADED = 'message.auto_ban.storage_degraded'; + public const AUTO_BAN_EVALUATION_DEGRADED = 'message.auto_ban.evaluation_degraded'; + public const AUTO_BAN_PAYLOAD_INVALID = 'message.auto_ban.payload_invalid'; + public const AUTO_BAN_RESET_RELEASED = 'message.auto_ban.reset_released'; + public const AUTO_BAN_ALERT_DELIVERY_DEGRADED = 'message.auto_ban.alert_delivery_degraded'; } diff --git a/src/Security/SessionVisitorBindingSubscriber.php b/src/Security/SessionVisitorBindingSubscriber.php index 139eccf4..8ced1338 100644 --- a/src/Security/SessionVisitorBindingSubscriber.php +++ b/src/Security/SessionVisitorBindingSubscriber.php @@ -5,6 +5,7 @@ namespace App\Security; use App\Core\Access\AccessActor; +use App\Core\Access\AccessLevel; use App\Core\Log\AccessRequestMetadata; use App\Core\Log\AuditLoggerInterface; use App\Core\Statistics\VisitorIdGenerator; @@ -12,7 +13,9 @@ use App\Security\Abuse\AbuseRequestInspector; use App\Security\Abuse\AbuseSubjectType; use App\Security\Abuse\SecuritySignalRecorder; -use DateTimeImmutable; +use App\Security\AutoBan\AutoBanPolicy; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Clock\NativeClock; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -41,6 +44,8 @@ public function __construct( private ?AbuseRequestInspector $abuseInspector = null, private ?SecuritySignalRecorder $securitySignals = null, private ?AccessRequestMetadata $accessRequestMetadata = null, + private ?AutoBanPolicy $autoBanPolicy = null, + private ClockInterface $clock = new NativeClock(), ) { } @@ -96,7 +101,7 @@ public function onKernelRequest(RequestEvent $event): void } $changeCount = max(0, (int) $session->get(self::SESSION_VISITOR_CHANGE_COUNT, 0)) + 1; - $changedAt = (new DateTimeImmutable())->format(DATE_ATOM); + $changedAt = $this->clock->now()->format(DATE_ATOM); $session->set(self::SESSION_PREVIOUS_VISITOR_ID, $boundVisitorId); $session->set(self::SESSION_VISITOR_CHANGED_AT, $changedAt); @@ -182,6 +187,38 @@ private function safeSignal( 'ip_bucket' => $ipBucket?->identifier(), ], ); + + $accessLevel = $subjects->first(AbuseSubjectType::User)?->context()['access_level'] ?? AccessLevel::PUBLIC; + $trustedLevel = $this->autoBanPolicy?->trustedAccessLevel() ?? AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL; + if (is_numeric($accessLevel) && (int) $accessLevel >= $trustedLevel) { + return; + } + + foreach (array_filter([$visitor, $ipBucket]) as $sourceSubject) { + $this->securitySignals->record( + 'session', + 'security.signal.session_visitor_mismatch', + $sourceSubject->type()->value, + $sourceSubject->identifier(), + ipDerived: $sourceSubject->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: $this->accessRequestMetadata?->sanitizedPath($request) ?? $profile->path(), + route: $profile->route(), + context: [ + 'previous_visitor_id' => $previousVisitorId, + 'current_visitor_id' => $currentVisitorId, + 'change_count' => $changeCount, + 'changed_at' => $changedAt, + 'ip_bucket' => $ipBucket?->identifier(), + 'source_signal' => true, + ], + ); + } } catch (Throwable) { return; } diff --git a/tests/Core/Message/MessageCodeTest.php b/tests/Core/Message/MessageCodeTest.php index 212a2226..788a8dd1 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_', 'RATE_LIMIT_'], + SecurityMessageCode::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_', 'RATE_LIMIT_', 'AUTO_BAN_'], 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.', 'rate_limit.'], + SecurityMessageCode::class => ['acl.', 'user.', 'account.', 'api_key.', 'rate_limit.', 'auto_ban.'], SetupMessageCode::class => ['setup.'], ViewMessageCode::class => ['view.'], ]; diff --git a/tests/Core/Message/MessageKeyTest.php b/tests/Core/Message/MessageKeyTest.php index 2b73dd1c..659ca6ec 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_', 'RATE_LIMIT_'], + SecurityMessageKey::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_', 'RATE_LIMIT_', 'AUTO_BAN_'], 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.', 'message.rate_limit.'], + SecurityMessageKey::class => ['message.acl.', 'message.user.', 'message.account_', 'message.api_key.', 'message.rate_limit.', 'message.auto_ban.'], SetupMessageKey::class => ['message.setup.'], ViewMessageKey::class => ['message.view.'], ]; diff --git a/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php index 0860ad25..f24f082d 100644 --- a/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php +++ b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php @@ -53,6 +53,7 @@ public function testItRecordsSuspiciousProbeSignalsWithVisitorAndIpBucketContext new Response('', 400), )); + self::assertSame(2, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); $row = $connection->fetchAssociative('SELECT * FROM security_signal_event'); self::assertIsArray($row); $context = json_decode((string) $row['context'], true, flags: JSON_THROW_ON_ERROR); @@ -62,6 +63,7 @@ public function testItRecordsSuspiciousProbeSignalsWithVisitorAndIpBucketContext self::assertSame('visitor', $row['subject_type']); self::assertSame($visitorIds->generate($request), $row['visitor_id']); self::assertSame($row['visitor_id'], $row['subject_identifier']); + self::assertSame(1, (int) $connection->fetchOne("SELECT COUNT(*) FROM security_signal_event WHERE subject_type = 'ip_bucket'")); 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)); @@ -93,6 +95,40 @@ public function testItDoesNotRecordOrdinaryNavigationSignals(): void self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); } + public function testItRecordsErrorStatusSignalsButExcludesLoginRequired401(): 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('/missing', server: ['REMOTE_ADDR' => '203.0.113.10']), + HttpKernelInterface::MAIN_REQUEST, + new Response('', 404), + )); + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']), + HttpKernelInterface::MAIN_REQUEST, + new Response('', 401), + )); + + self::assertSame(2, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + self::assertSame(2, (int) $connection->fetchOne("SELECT COUNT(*) FROM security_signal_event WHERE reason_code = 'security.signal.error_http_status'")); + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event WHERE http_status = 401')); + } + public function testItSanitizesTokenizedPathsBeforeRecordingSignals(): void { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php new file mode 100644 index 00000000..586b14f9 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -0,0 +1,155 @@ + '203.0.113.10']); + $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'request-ban'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertInstanceOf(Response::class, $response); + self::assertSame(403, $response->getStatusCode()); + self::assertSame('3600', $response->headers->get('Retry-After')); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + self::assertStringContainsString('Request blocked due to suspicious activity.', (string) $response->getContent()); + self::assertStringContainsString('request-ban', (string) $response->getContent()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testRecoveryLoginBypassIsReachableDespiteActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/de/users/login?bypass=1', server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_locale', 'de'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testTrustedUsersBypassActiveVisitorBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000001', 'manager', 'manager@example.test', 'hash', role: UserRole::Manager); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock, $tokenStorage)->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testSubscriberRunsAfterSecurityContextButBeforeOrdinaryRateLimit(): void + { + $autoBan = AutoBanRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + $rateLimit = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + + self::assertSame(['onKernelRequest', 4], $autoBan); + self::assertSame(['onKernelRequestOrdinary', 3], $rateLimit[1]); + self::assertGreaterThan($rateLimit[1][1], $autoBan[1]); + } + + private function subscriber( + VisitorIdGenerator $visitorIds, + AutoBanStore $store, + MockClock $clock, + ?TokenStorage $tokenStorage = null, + ): AutoBanRequestSubscriber { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return new AutoBanRequestSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, $tokenStorage ?? new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new AutoBanPolicy(new Config($connection)), + $store, + $this->renderer(), + new AccessRequestMetadata(), + clock: $clock, + ); + } + + private function renderer(): HttpErrorRenderer + { + return new HttpErrorRenderer( + new Environment(new ArrayLoader()), + (new \ReflectionClass(PublishedContentResolver::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(ContentFieldsetRenderer::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(Security::class))->newInstanceWithoutConstructor(), + new SetupCompletionMarker(), + new AccessRequestMetadata(), + sys_get_temp_dir(), + 'test', + ); + } +} + +final class AutoBanRequestTestKernel implements HttpKernelInterface +{ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } +} diff --git a/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php b/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php new file mode 100644 index 00000000..a2438fbb --- /dev/null +++ b/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php @@ -0,0 +1,215 @@ +stack(); + $visitor = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-1'); + + $this->recordProbe($recorder, $visitor, 'request-1'); + + self::assertNull($store->active($visitor)); + + $this->recordProbe($recorder, $visitor, 'request-2'); + + self::assertNotNull($store->active($visitor)); + } + + public function testIpSubjectUsesLaxerThresholdMultiplier(): void + { + [$recorder, $store] = $this->stack(); + $ip = new AutoBanSubject(AutoBanSubject::IP, 'ip-bucket-1', true); + + for ($i = 1; $i <= 28; ++$i) { + $this->recordError($recorder, $ip, 'request-'.$i); + } + + self::assertNull($store->active($ip)); + + $this->recordError($recorder, $ip, 'request-29'); + + self::assertNotNull($store->active($ip)); + } + + public function testResetSignalInvalidatesEarlierScoreEvidence(): void + { + [$recorder, $store] = $this->stack(); + $visitor = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-reset'); + + $this->recordProbe($recorder, $visitor, 'request-1'); + $this->recordProbe($recorder, $visitor, 'request-2'); + self::assertNotNull($store->active($visitor)); + + $store->reset($visitor->key()); + $recorder->record( + 'auto_ban', + AutoBanScoreCatalogue::SIGNAL_RESET, + $visitor->type(), + $visitor->identifier(), + requestId: 'request-reset', + ); + + $this->recordProbe($recorder, $visitor, 'request-3'); + + self::assertNull($store->active($visitor)); + } + + public function testSameEvaluationPrefersVisitorBanOverIpBan(): void + { + [$recorder, $store] = $this->stack(); + $visitor = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-shared'); + $ip = new AutoBanSubject(AutoBanSubject::IP, 'ip-shared', true); + + $this->recordProbe($recorder, $visitor, 'request-1'); + $this->recordProbe($recorder, $ip, 'request-1'); + $this->recordProbe($recorder, $visitor, 'request-2'); + $this->recordProbe($recorder, $ip, 'request-2'); + + self::assertNotNull($store->active($visitor)); + self::assertNull($store->active($ip)); + } + + /** + * @return array{0: SecuritySignalRecorder, 1: AutoBanStore, 2: Connection} + */ + private function stack(): array + { + $connection = $this->connection(); + $clock = new MockClock('2026-06-18 12:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $evaluator = new AutoBanSignalEvaluator( + $connection, + new DatabaseLogRetentionPolicy($connection), + new AutoBanPolicy(new Config($connection)), + new AutoBanScoreCatalogue(), + $store, + clock: $clock, + ); + $recorder = new SecuritySignalRecorder( + $connection, + new DatabaseLogRetentionPolicy($connection), + clock: $clock, + autoBanSignals: $evaluator, + ); + + return [$recorder, $store, $connection]; + } + + private function recordProbe(SecuritySignalRecorder $recorder, AutoBanSubject $subject, string $requestId): void + { + $recorder->record( + 'probe', + AutoBanScoreCatalogue::SIGNAL_SUSPICIOUS_PROBE, + $subject->type(), + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: 'WARNING', + confidence: 95, + requestId: $requestId, + visitorId: 'visitor-context', + httpStatus: 400, + ); + } + + private function recordError(SecuritySignalRecorder $recorder, AutoBanSubject $subject, string $requestId): void + { + $recorder->record( + 'http_error', + AutoBanScoreCatalogue::SIGNAL_ERROR_HIT, + $subject->type(), + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: 'NOTICE', + confidence: 40, + requestId: $requestId, + visitorId: 'visitor-context', + httpStatus: 404, + ); + } + + public function testInvalidActiveBanPayloadFailsOpenWithMessage(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $cache = new ArrayAdapter(); + $messages = new RecordingAutoBanMessageReporter(); + $store = new AutoBanStore($cache, new LockFactory(new InMemoryStore()), $messages, $clock); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-invalid-payload'); + $item = $cache->getItem('security.auto_ban.active.'.$subject->key()); + $item->set([ + 'key' => $subject->key(), + 'subject_type' => $subject->type(), + 'subject_identifier' => $subject->identifier(), + 'created_at' => 'not-a-date', + 'expires_at' => '2026-06-18 13:00:00', + 'ttl_seconds' => 3600, + ]); + $cache->save($item); + + self::assertNull($store->active($subject)); + self::assertSame(SecurityMessageCode::AUTO_BAN_PAYLOAD_INVALID, $messages->records[0]['message']->code()); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE INDEX idx_security_signal_subject_at ON security_signal_event (subject_type, subject_identifier, occurred_at)'); + + return $connection; + } +} + +final class RecordingAutoBanMessageReporter implements MessageReporterInterface +{ + /** + * @var list}> + */ + public array $records = []; + + public function report(Message $message, array $context = []): Message + { + $this->records[] = ['message' => $message, 'context' => $context]; + + return $message; + } + + public function reportBatch(iterable $records): array + { + $messages = []; + foreach ($records as $record) { + $message = $record['message']; + if (!$message instanceof Message) { + continue; + } + + $messages[] = $this->report($message, $record['context'] ?? []); + } + + return $messages; + } +} diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index 48dd417d..256de899 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -99,6 +99,12 @@ public function testRecoveryLoginBypassUsesDedicatedBucketWithoutWebsiteBudget() self::assertFalse($result->isAllowed()); self::assertSame('security.rate.recovery_login', $result->diagnosticsLabel()); + + $localizedRecovery = $this->request('/de/users/login?bypass=1'); + $localizedRecovery->attributes->set('_route', 'user_login_recovery_locale_alias'); + $localizedRecovery->attributes->set('_locale', 'de'); + + self::assertFalse($enforcer->check($localizedRecovery, RateLimitEnforcementStage::Ordinary)->isAllowed()); } public function testPanicRecoveryLoginRenderAndSubmitFitBudgets(): void diff --git a/translations/languages/de/message.yaml b/translations/languages/de/message.yaml index 0f190e2d..5f9a7f45 100644 --- a/translations/languages/de/message.yaml +++ b/translations/languages/de/message.yaml @@ -124,6 +124,12 @@ message: request_rejected: 'Die Anfrage konnte nicht akzeptiert werden.' storage_degraded: 'Rate-Limit-Speicher war nicht verfügbar; die Anfrage wurde zugelassen.' reset_degraded: 'Rate-Limit-Reset-Speicher war nicht verfügbar.' + auto_ban: + storage_degraded: 'Auto-Ban-Speicher war nicht verfügbar; Enforcement ist fail-open weitergelaufen.' + evaluation_degraded: 'Auto-Ban-Score-Auswertung war nicht verfügbar; die Anfrage wurde zugelassen.' + payload_invalid: 'Auto-Ban-State enthielt eine ungültige Payload und wurde ignoriert.' + reset_released: 'Auto-Ban wurde gelöst.' + alert_delivery_degraded: 'Auto-Ban-Owner-Alert-Zustellung war nicht verfügbar.' event: hook: invalid: 'Event-Hook "%event%" ist keine gültige öffentliche Hook-Definition.' diff --git a/translations/languages/en/message.yaml b/translations/languages/en/message.yaml index f2be7e66..ffeb62e1 100644 --- a/translations/languages/en/message.yaml +++ b/translations/languages/en/message.yaml @@ -124,6 +124,12 @@ message: request_rejected: 'The request could not be accepted.' storage_degraded: 'Rate-limit storage was unavailable; the request was allowed.' reset_degraded: 'Rate-limit reset storage was unavailable.' + auto_ban: + storage_degraded: 'Auto-ban storage was unavailable; enforcement failed open.' + evaluation_degraded: 'Auto-ban score evaluation was unavailable; the request was allowed.' + payload_invalid: 'Auto-ban state contained an invalid payload and was ignored.' + reset_released: 'Auto-ban released.' + alert_delivery_degraded: 'Auto-ban owner alert delivery was unavailable.' event: hook: invalid: 'Event hook "%event%" is not a valid public hook definition.' From 601cb2769321d97999a16c3d375b3ff7d531a3a3 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 13:09:10 +0200 Subject: [PATCH 04/55] Add recovery login alias for auto-ban bypass --- src/Controller/SecurityController.php | 2 ++ src/Core/Routing/RequestPathResolver.php | 2 +- src/Security/Abuse/RequestIntentClassifier.php | 14 +++++++++++--- tests/Core/Routing/RequestPathResolverTest.php | 3 +++ .../Security/Abuse/RequestIntentClassifierTest.php | 5 +++++ 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 22620b76..ace0cfd3 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -19,6 +19,8 @@ public function __construct( } #[Route('/user/login', name: 'user_login', methods: ['GET', 'POST'])] + #[Route('/users/login', name: 'user_login_recovery_alias', methods: ['GET'])] + #[Route('/{_locale}/users/login', name: 'user_login_recovery_locale_alias', requirements: ['_locale' => '[A-Za-z]{2}(?:-[A-Za-z0-9]+)?'], methods: ['GET'])] public function login(AuthenticationUtils $authenticationUtils, Request $request): Response { return $this->render('@frontend/user/login.html.twig', [ diff --git a/src/Core/Routing/RequestPathResolver.php b/src/Core/Routing/RequestPathResolver.php index b9045806..6f9cdfd4 100644 --- a/src/Core/Routing/RequestPathResolver.php +++ b/src/Core/Routing/RequestPathResolver.php @@ -12,7 +12,7 @@ /** * @var list */ - private const LOCALE_PREFIX_SCOPED_SEGMENTS = ['admin', 'editor', 'user']; + private const LOCALE_PREFIX_SCOPED_SEGMENTS = ['admin', 'editor', 'user', 'users']; public function __construct(private ?ContentRouteLocalization $routeLocalization = null) { diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index 74266528..6b2b71e6 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -129,7 +129,7 @@ private function intent( } 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_login') || $this->loginSegments($segments)) => 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, @@ -140,11 +140,19 @@ private function intent( private function recoveryLogin(Request $request, string $method, array $segments, string $route): bool { return 'GET' === $method - && $this->matchesSegments($segments, 'user', 'login') - && $this->routeIs($route, 'user_login', 'n/a') + && $this->loginSegments($segments) + && $this->routeIs($route, 'user_login', 'user_login_recovery_alias', 'user_login_recovery_locale_alias', 'n/a') && '1' === (string) $request->query->get('bypass', ''); } + /** + * @param list $segments + */ + private function loginSegments(array $segments): bool + { + return $this->matchesSegments($segments, 'user', 'login') || $this->matchesSegments($segments, 'users', 'login'); + } + private function setupApply(Request $request, array $segments): bool { return $this->matchesExactSegments($segments, 'setup', 'review') diff --git a/tests/Core/Routing/RequestPathResolverTest.php b/tests/Core/Routing/RequestPathResolverTest.php index 208b7e5a..bf48aea9 100644 --- a/tests/Core/Routing/RequestPathResolverTest.php +++ b/tests/Core/Routing/RequestPathResolverTest.php @@ -21,10 +21,13 @@ public function testItStripsRouteLocaleOnlyBeforeKnownLocalePrefixPathScopes(): $resolver = new RequestPathResolver(); $admin = Request::create('/de/admin/settings/security'); $admin->attributes->set('_locale', 'de'); + $users = Request::create('/de/users/login'); + $users->attributes->set('_locale', 'de'); $content = Request::create('/de/about'); $content->attributes->set('_locale', 'de'); self::assertSame(['admin', 'settings', 'security'], $resolver->segments($admin)); + self::assertSame(['users', 'login'], $resolver->segments($users)); self::assertSame(['de', 'about'], $resolver->segments($content)); } diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index 4c4dd651..2d8d0f45 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -264,6 +264,11 @@ public static function requestCases(): iterable RequestFamily::Browser, RequestIntent::RecoveryLogin, ]; + yield 'plural recovery login bypass uses recovery intent' => [ + self::localizedRequest('/de/users/login?bypass=1', 'GET', 'de'), + RequestFamily::Browser, + RequestIntent::RecoveryLogin, + ]; yield 'recovery login bypass post stays login intent' => [ Request::create('/user/login?bypass=1', 'POST'), RequestFamily::Browser, From d44be503dcdfc92a214735f389e6187d6d990901 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 13:09:30 +0200 Subject: [PATCH 05/55] Expose owner auto-ban review workflows --- .../Documentation/OpenApiDocumentFactory.php | 1 + src/Backend/AdminViewContextProvider.php | 5 + src/Controller/AdminAutoBanController.php | 167 ++++++++++++++ src/Controller/BackendController.php | 1 + .../Config/Settings/CoreSettingsRegistry.php | 22 ++ .../Api/AutoBanApiEndpointProvider.php | 81 +++++++ .../AutoBan/Api/AutoBanApiHandler.php | 214 ++++++++++++++++++ src/Security/AutoBan/AutoBanAdminBrowser.php | 138 +++++++++++ .../AutoBan/AutoBanOwnerAlertNotifier.php | 88 +++++++ .../admin/security/auto-ban-detail.html.twig | 64 ++++++ .../admin/security/auto-bans.html.twig | 43 ++++ .../backend/admin/settings/section.html.twig | 9 + .../ApiAdminOperationalControllerTest.php | 84 ++++++- tests/Controller/BackendControllerTest.php | 42 +++- .../Config/CoreSettingsFormHandlerTest.php | 9 + .../Core/Config/CoreSettingsRegistryTest.php | 24 +- .../AutoBan/AutoBanOwnerAlertNotifierTest.php | 150 ++++++++++++ translations/languages/de/admin.yaml | 57 +++++ translations/languages/en/admin.yaml | 57 +++++ 19 files changed, 1247 insertions(+), 9 deletions(-) create mode 100644 src/Controller/AdminAutoBanController.php create mode 100644 src/Security/AutoBan/Api/AutoBanApiEndpointProvider.php create mode 100644 src/Security/AutoBan/Api/AutoBanApiHandler.php create mode 100644 src/Security/AutoBan/AutoBanAdminBrowser.php create mode 100644 src/Security/AutoBan/AutoBanOwnerAlertNotifier.php create mode 100644 templates/backend/admin/security/auto-ban-detail.html.twig create mode 100644 templates/backend/admin/security/auto-bans.html.twig create mode 100644 tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php diff --git a/src/Api/Documentation/OpenApiDocumentFactory.php b/src/Api/Documentation/OpenApiDocumentFactory.php index 7f2fe08c..d88f3378 100644 --- a/src/Api/Documentation/OpenApiDocumentFactory.php +++ b/src/Api/Documentation/OpenApiDocumentFactory.php @@ -409,6 +409,7 @@ private function tagMetadata(): array 'backend-admin-packages' => ['summary' => 'Backend Admin Packages', 'description' => 'Administrative package management and lifecycle resources.', 'parent' => 'backend-admin', 'kind' => 'nav'], 'backend-admin-permissions' => ['summary' => 'Backend Admin Permissions', 'description' => 'Endpoint access and API key capability matrix resources.', 'parent' => 'backend-admin', 'kind' => 'nav'], 'backend-admin-scheduler' => ['summary' => 'Backend Admin Scheduler', 'description' => 'Administrative scheduler task and run resources.', 'parent' => 'backend-admin', 'kind' => 'nav'], + 'backend-admin-security' => ['summary' => 'Backend Admin Security', 'description' => 'Administrative security configuration, signals, and auto-ban resources.', 'parent' => 'backend-admin', 'kind' => 'nav'], 'backend-admin-settings' => ['summary' => 'Backend Admin Settings', 'description' => 'Administrative settings sections and values.', 'parent' => 'backend-admin', 'kind' => 'nav'], 'backend-admin-statistics' => ['summary' => 'Backend Admin Statistics', 'description' => 'Administrative access statistics resources.', 'parent' => 'backend-admin', 'kind' => 'nav'], 'backend-admin-themes' => ['summary' => 'Backend Admin Themes', 'description' => 'Administrative frontend and backend theme resources.', 'parent' => 'backend-admin', 'kind' => 'nav'], diff --git a/src/Backend/AdminViewContextProvider.php b/src/Backend/AdminViewContextProvider.php index a394b5ce..62a41945 100644 --- a/src/Backend/AdminViewContextProvider.php +++ b/src/Backend/AdminViewContextProvider.php @@ -18,6 +18,7 @@ use App\Core\Operation\Live\LiveOperationRunStore; use App\Core\Statistics\AccessStatisticsSnapshotProvider; use App\Entity\UserAccount; +use App\Security\AutoBan\AutoBanPolicy; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; @@ -35,6 +36,7 @@ public function __construct( private AdminFeatureRegistry $adminFeatureRegistry, private AdminFeatureAccessPolicy $adminFeatureAccessPolicy, private AdminFeatureOverrideStore $adminFeatureOverrideStore, + private AutoBanPolicy $autoBanPolicy, ) { } @@ -64,6 +66,9 @@ public function variables(Request $request, ?BackendViewDefinition $view): array 'status' => $this->geoIpResolver->status()->toSafeArray(), ], ], + 'backend-admin-settings-security' => [ + 'auto_ban_enabled' => $this->autoBanPolicy->enabled(), + ], 'backend-admin-settings-acl' => [ 'acl_matrix' => $this->aclMatrix(), ], diff --git a/src/Controller/AdminAutoBanController.php b/src/Controller/AdminAutoBanController.php new file mode 100644 index 00000000..7f2ca988 --- /dev/null +++ b/src/Controller/AdminAutoBanController.php @@ -0,0 +1,167 @@ +accessResponse($request, mutable: false)) { + return $response; + } + + return $this->render('@backend/admin/security/auto-bans.html.twig', [ + 'area' => BackendArea::Admin, + 'active_auto_bans' => $this->browser->activeList(), + ]); + } + + #[Route('/admin/security/auto-bans/{key}', name: 'backend_admin_auto_ban_detail', requirements: ['key' => '[a-f0-9]{40}'], methods: ['GET'])] + public function detail(Request $request, string $key): Response + { + if ($response = $this->accessResponse($request, mutable: false)) { + return $response; + } + + $detail = $this->browser->detail($key); + if (null === $detail) { + return $this->httpError->notFound($request); + } + + return $this->render('@backend/admin/security/auto-ban-detail.html.twig', [ + 'area' => BackendArea::Admin, + 'auto_ban_detail' => $detail, + 'auto_ban_reset_mutable' => $this->adminAcl->isMutable('admin.settings.security', $this->actor()), + 'reset_form_id' => $this->resetFormId($key), + ]); + } + + #[Route('/admin/security/auto-bans/{key}/reset', name: 'backend_admin_auto_ban_reset', requirements: ['key' => '[a-f0-9]{40}'], methods: ['POST'])] + public function reset(Request $request, string $key): Response + { + if ($response = $this->accessResponse($request, mutable: true)) { + return $response; + } + + if (!$this->formTokenValidator->isValid($this->resetFormId($key), (string) $request->request->get('_form_id', ''), (string) $request->request->get('_csrf_token', ''))) { + return $this->httpError->resolve(Response::HTTP_FORBIDDEN, $request, context: ['auto_ban_key' => $key]); + } + + $ban = $this->store->reset($key); + if (null !== $ban) { + $this->signals->record( + 'auto_ban', + AutoBanScoreCatalogue::SIGNAL_RESET, + $ban->subjectType(), + $ban->subjectIdentifier(), + ipDerived: 'ip_bucket' === $ban->subjectType(), + severity: 'NOTICE', + confidence: 100, + requestFamily: 'admin', + requestIntent: 'settings_mutation', + requestId: $this->requestMetadata->requestId($request), + visitorId: 'n/a', + path: $this->requestMetadata->sanitizedPath($request), + route: 'backend_admin_auto_ban_reset', + context: [ + 'active_ban_key' => $key, + 'effective_subject_type' => 'ip_bucket' === $ban->subjectType() ? 'ip' : 'visitor', + 'reset_by' => $this->actor()->userUid(), + ], + ); + $this->auditReset($key, $ban->subjectType()); + $this->alerts->addAlert(UiAlertTranslation::success('admin.auto_bans.reset.saved'), UiAlertDelivery::Direct); + } else { + $this->alerts->addAlert(UiAlertTranslation::error('admin.auto_bans.reset.failed'), UiAlertDelivery::Direct); + } + + return $this->redirectToRoute('backend_admin_route', ['path' => 'settings/security']); + } + + private function accessResponse(Request $request, bool $mutable): ?Response + { + $decision = $this->accessGuard->decide(BackendArea::Admin, $this->getUser()); + if (!$decision->isGranted()) { + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'area' => BackendArea::Admin->value, + 'access_decision' => $decision->toArray(), + ]); + } + + $actor = $this->actor(); + $allowed = $mutable + ? $this->adminAcl->isMutable('admin.settings.security', $actor) + : $this->adminAcl->isVisible('admin.settings.security', $actor); + if (!$allowed) { + return $this->httpError->resolve(Response::HTTP_UNAUTHORIZED, $request, context: [ + 'feature' => 'admin.settings.security', + 'required_state' => $mutable ? 'mutable' : 'visible', + ]); + } + + return null; + } + + private function actor(): AccessActor + { + $user = $this->getUser(); + + return $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + } + + private function resetFormId(string $key): string + { + return 'admin-auto-ban-reset-'.$key; + } + + private function auditReset(string $key, string $subjectType): void + { + try { + $this->auditLogger->log($this->actor(), 'security.auto_ban.reset', [ + 'active_ban_key' => $key, + 'subject_type' => $subjectType, + ]); + } catch (Throwable) { + return; + } + } +} diff --git a/src/Controller/BackendController.php b/src/Controller/BackendController.php index b902307d..0f378af9 100644 --- a/src/Controller/BackendController.php +++ b/src/Controller/BackendController.php @@ -300,6 +300,7 @@ private function handleAdminPost(Request $request, BackendViewDefinition $view): $request->attributes->set('_system_form_values', $result->values()); $request->attributes->set('_system_form_errors', $result->errors()); + $this->alerts->addAlert(UiAlertTranslation::error('admin.settings.form.errors.save_failed'), UiAlertDelivery::Direct); return null; } diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index ef6adc12..481b9dec 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -14,6 +14,7 @@ use App\Form\FormInputType; use App\Localization\TranslationLanguageCatalog; use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\AutoBan\AutoBanPolicy; use App\Security\RateLimit\RateLimitPolicyCatalogue; use App\Security\RateLimit\RateLimitProfile; use App\Security\UserFlowConfig; @@ -94,6 +95,27 @@ public function allDefinitions(): array ], validation: ['required' => true], metadata: [ 'access_feature' => 'admin.settings.security', ], sortOrder: 35), + new CoreSettingDefinition('security', AutoBanPolicy::ENABLED_KEY, 'admin.settings.fields.auto_ban_enabled.label', AutoBanPolicy::DEFAULT_ENABLED, ConfigValueType::Boolean, help: 'admin.settings.fields.auto_ban_enabled.help', metadata: [ + 'access_feature' => 'admin.settings.security', + 'minimum_access_level' => AccessLevel::OWNER, + ], sortOrder: 36), + new CoreSettingDefinition('security', AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 'admin.settings.fields.auto_ban_trusted_access_level.label', AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, ConfigValueType::Integer, FormInputType::Select, help: 'admin.settings.fields.auto_ban_trusted_access_level.help', options: [ + (string) AccessLevel::MANAGER => 'admin.settings.options.access_level.manager', + (string) AccessLevel::DIRECTOR => 'admin.settings.options.access_level.director', + (string) AccessLevel::ADMIN => 'admin.settings.options.access_level.admin', + (string) AccessLevel::OWNER => 'admin.settings.options.access_level.owner', + ], validation: ['required' => true, 'min' => AccessLevel::MANAGER, 'max' => AccessLevel::OWNER], metadata: [ + 'access_feature' => 'admin.settings.security', + 'minimum_access_level' => AccessLevel::OWNER, + ], sortOrder: 37), + new CoreSettingDefinition('security', AutoBanPolicy::SCORE_THRESHOLD_KEY, 'admin.settings.fields.auto_ban_score_threshold.label', AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.auto_ban_score_threshold.help', validation: ['required' => true, 'min' => 2, 'max' => 10000], metadata: [ + 'access_feature' => 'admin.settings.security', + 'minimum_access_level' => AccessLevel::OWNER, + ], sortOrder: 38), + new CoreSettingDefinition('security', AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY, 'admin.settings.fields.auto_ban_new_ban_owner_alerts.label', AutoBanPolicy::DEFAULT_NEW_BAN_OWNER_ALERTS, ConfigValueType::Boolean, help: 'admin.settings.fields.auto_ban_new_ban_owner_alerts.help', metadata: [ + 'access_feature' => 'admin.settings.security', + 'minimum_access_level' => AccessLevel::OWNER, + ], sortOrder: 39), new CoreSettingDefinition('security', ConfigAuditLogPolicy::ENABLED_KEY, 'admin.settings.fields.audit_enabled.label', true, ConfigValueType::Boolean, metadata: [ 'access_feature' => 'admin.settings.security', ], sortOrder: 40), diff --git a/src/Security/AutoBan/Api/AutoBanApiEndpointProvider.php b/src/Security/AutoBan/Api/AutoBanApiEndpointProvider.php new file mode 100644 index 00000000..231103fb --- /dev/null +++ b/src/Security/AutoBan/Api/AutoBanApiEndpointProvider.php @@ -0,0 +1,81 @@ +endpoint( + Request::METHOD_GET, + '/api/v1/admin/security/auto-bans', + 'listAdminSecurityAutoBans', + 'List active security auto-bans visible to Owners.', + ), + $this->endpoint( + Request::METHOD_GET, + '/api/v1/admin/security/auto-bans/{key}', + 'getAdminSecurityAutoBan', + 'Return one active security auto-ban and related security signals.', + parameters: $this->keyParameters(), + pathPattern: '#^/api/v1/admin/security/auto-bans/[a-f0-9]{40}$#', + ), + $this->endpoint( + Request::METHOD_POST, + '/api/v1/admin/security/auto-bans/{key}/reset', + 'resetAdminSecurityAutoBan', + 'Release one active security auto-ban and record the reset signal.', + parameters: $this->keyParameters(), + responseSchema: ['type' => 'object'], + pathPattern: '#^/api/v1/admin/security/auto-bans/[a-f0-9]{40}/reset$#', + ), + ]; + } + + /** + * @param list> $parameters + * @param array|null $responseSchema + */ + private function endpoint( + string $method, + string $path, + string $operationId, + string $summary, + array $parameters = [], + ?array $responseSchema = null, + ?string $pathPattern = null, + ): ApiEndpointDefinition { + return new ApiEndpointDefinition( + 'security', + $method, + $path, + 'api_v1_endpoint_dispatch', + $operationId, + $summary, + self::HANDLER_AUTO_BANS, + ['backend-admin', 'backend-admin-security'], + parameters: $parameters, + responseSchema: $responseSchema ?? ['type' => 'object'], + pathPattern: $pathPattern, + ); + } + + /** + * @return list> + */ + private function keyParameters(): array + { + return [ + ['name' => 'key', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string', 'pattern' => '^[a-f0-9]{40}$']], + ]; + } +} diff --git a/src/Security/AutoBan/Api/AutoBanApiHandler.php b/src/Security/AutoBan/Api/AutoBanApiHandler.php new file mode 100644 index 00000000..d5871146 --- /dev/null +++ b/src/Security/AutoBan/Api/AutoBanApiHandler.php @@ -0,0 +1,214 @@ +featureGuard->denyUnlessVisible($request, 'admin.settings.security', $endpoint->operationId())) { + return $denied; + } + + $key = $this->keyFromPath($request->getPathInfo()); + if ($request->isMethod(Request::METHOD_POST)) { + return null === $key ? $this->notFound($request, null) : $this->reset($request, $key, $endpoint); + } + + if (null !== $key) { + $detail = $this->browser->detail($key); + + return null === $detail + ? $this->notFound($request, $key) + : $this->responder->data($this->detailResource($detail)); + } + + $resources = array_map($this->banResource(...), $this->browser->activeList()); + + return $this->responder->data($resources, meta: ['count' => count($resources)]); + } + + private function reset(Request $request, string $key, ApiEndpointDefinition $endpoint): Response + { + if ($denied = $this->featureGuard->denyUnlessMutable($request, 'admin.settings.security', $endpoint->operationId())) { + return $denied; + } + + $ban = $this->store->reset($key); + if (null === $ban) { + return $this->operationUnavailable($request, $endpoint->operationId(), [ + 'active_ban_key' => $key, + 'reason' => 'active_ban_not_found', + ]); + } + + $this->signals->record( + 'auto_ban', + AutoBanScoreCatalogue::SIGNAL_RESET, + $ban->subjectType(), + $ban->subjectIdentifier(), + ipDerived: 'ip_bucket' === $ban->subjectType(), + severity: 'NOTICE', + confidence: 100, + requestFamily: 'api', + requestIntent: 'settings_mutation', + requestId: $this->requestMetadata->requestId($request), + visitorId: 'n/a', + path: $this->requestMetadata->sanitizedPath($request), + route: 'api_v1_endpoint_dispatch', + context: [ + 'active_ban_key' => $key, + 'effective_subject_type' => 'ip_bucket' === $ban->subjectType() ? 'ip' : 'visitor', + 'reset_by' => $this->actor($request)->userUid(), + 'api_operation' => $endpoint->operationId(), + ], + ); + $this->auditReset($request, $key, $ban->subjectType()); + + return $this->responder->data( + $this->banResource($ban->toArray()), + meta: [ + 'messages' => [ + $this->responder->message(Message::create( + SecurityMessageCode::AUTO_BAN_RESET_RELEASED, + SecurityMessageKey::AUTO_BAN_RESET_RELEASED, + level: MessageLevel::Success, + ), $request), + ], + ], + ); + } + + private function keyFromPath(string $path): ?string + { + if (1 !== preg_match('#^/api/v1/admin/security/auto-bans/([a-f0-9]{40})(?:/reset)?$#', $path, $matches)) { + return null; + } + + return $matches[1]; + } + + /** + * @param array $ban + * + * @return array + */ + private function banResource(array $ban): array + { + $key = (string) ($ban['key'] ?? ''); + + return [ + 'type' => 'security_auto_ban', + 'id' => $key, + 'attributes' => $ban, + 'links' => [ + 'self' => '/api/v1/admin/security/auto-bans/'.$key, + 'reset' => '/api/v1/admin/security/auto-bans/'.$key.'/reset', + 'admin' => '/admin/security/auto-bans/'.$key, + ], + ]; + } + + /** + * @param array{ban: array, signals: list>} $detail + * + * @return array + */ + private function detailResource(array $detail): array + { + return [ + ...$this->banResource($detail['ban']), + 'relationships' => [ + 'signals' => array_map(static fn (array $signal): array => [ + 'type' => 'security_signal', + 'id' => (string) ($signal['uid'] ?? ''), + 'attributes' => $signal, + ], $detail['signals']), + ], + ]; + } + + private function notFound(Request $request, ?string $key): Response + { + return $this->responder->error( + Message::warning(ApiMessageCode::API_ENDPOINT_NOT_FOUND, ApiMessageKey::API_ENDPOINT_NOT_FOUND, context: [ + 'path' => $request->getPathInfo(), + 'active_ban_key' => $key, + ]), + Response::HTTP_NOT_FOUND, + $request, + ); + } + + /** + * @param array $context + */ + private function operationUnavailable(Request $request, string $operation, array $context): Response + { + return $this->responder->error( + Message::warning(ApiMessageCode::API_OPERATION_UNAVAILABLE, ApiMessageKey::API_OPERATION_UNAVAILABLE, [ + '%operation%' => $operation, + ], $context), + Response::HTTP_CONFLICT, + $request, + ); + } + + private function actor(Request $request): AccessActor + { + return ApiRequestContext::fromRequest($request)?->actor() ?? AccessActor::anonymous(); + } + + private function auditReset(Request $request, string $key, string $subjectType): void + { + try { + $this->auditLogger->log($this->actor($request), 'security.auto_ban.reset', [ + 'active_ban_key' => $key, + 'subject_type' => $subjectType, + 'surface' => 'api', + ]); + } catch (Throwable) { + return; + } + } +} diff --git a/src/Security/AutoBan/AutoBanAdminBrowser.php b/src/Security/AutoBan/AutoBanAdminBrowser.php new file mode 100644 index 00000000..c38ec733 --- /dev/null +++ b/src/Security/AutoBan/AutoBanAdminBrowser.php @@ -0,0 +1,138 @@ +> + */ + public function activeList(): array + { + return array_map( + static fn (ActiveAutoBan $ban): array => $ban->toArray(), + $this->store->activeBans(), + ); + } + + /** + * @return array{ban: array, signals: list>}|null + */ + public function detail(string $key): ?array + { + $ban = $this->store->activeByKey($key); + if (!$ban instanceof ActiveAutoBan) { + return null; + } + + return [ + 'ban' => $ban->toArray(), + 'signals' => $this->signals($ban), + ]; + } + + /** + * @return list> + */ + private function signals(ActiveAutoBan $ban): array + { + try { + $rows = $this->connection->fetchAllAssociative( + 'SELECT uid, occurred_at, signal_type, reason_code, severity, confidence, request_id, visitor_id, path, route, http_status, context FROM '.self::TABLE.' WHERE subject_type = ? AND subject_identifier = ? AND expires_at > ? ORDER BY occurred_at DESC LIMIT 100', + [ + $ban->subjectType(), + $ban->subjectIdentifier(), + $this->clock->now()->format('Y-m-d H:i:s'), + ], + ); + + return array_map([$this, 'presentSignal'], $rows); + } catch (Throwable $error) { + $this->reportStorage('signals', $error, ['active_ban_key' => $ban->key()]); + + return []; + } + } + + /** + * @param array $row + * + * @return array + */ + private function presentSignal(array $row): array + { + return [ + 'uid' => (string) ($row['uid'] ?? ''), + 'occurred_at' => (string) ($row['occurred_at'] ?? ''), + 'signal_type' => (string) ($row['signal_type'] ?? ''), + 'reason_code' => (string) ($row['reason_code'] ?? ''), + 'severity' => (string) ($row['severity'] ?? ''), + 'confidence' => (int) ($row['confidence'] ?? 0), + 'request_id' => (string) ($row['request_id'] ?? ''), + 'visitor_id' => (string) ($row['visitor_id'] ?? ''), + 'path' => (string) ($row['path'] ?? ''), + 'route' => (string) ($row['route'] ?? ''), + 'http_status' => null === ($row['http_status'] ?? null) ? null : (int) $row['http_status'], + 'context' => $this->safeContext($row['context'] ?? null), + ]; + } + + /** + * @return array + */ + private function safeContext(mixed $context): array + { + if (is_array($context)) { + return $context; + } + + if (!is_string($context) || '' === trim($context)) { + return []; + } + + $decoded = json_decode($context, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $context + */ + private function reportStorage(string $operation, Throwable $error, array $context = []): void + { + try { + $this->messageReporter?->report(Message::exception( + SecurityMessageCode::AUTO_BAN_STORAGE_DEGRADED, + SecurityMessageKey::AUTO_BAN_STORAGE_DEGRADED, + context: [ + 'operation' => $operation, + 'exception' => $error::class, + 'message' => $error->getMessage(), + ...$context, + ], + ), ['component' => self::class]); + } catch (Throwable) { + } + } +} diff --git a/src/Security/AutoBan/AutoBanOwnerAlertNotifier.php b/src/Security/AutoBan/AutoBanOwnerAlertNotifier.php new file mode 100644 index 00000000..a2883f4f --- /dev/null +++ b/src/Security/AutoBan/AutoBanOwnerAlertNotifier.php @@ -0,0 +1,88 @@ +policy->newBanOwnerAlertsEnabled()) { + return; + } + + try { + $alert = UiAlertTranslation::warning('admin.auto_bans.alerts.triggered', [ + '%subject%' => $ban->subjectLabel(), + ]); + $presentation = UiAlertPresentation::hidden(actions: [ + UiAlertAction::link('Review', '/admin/security/auto-bans'), + ], id: 'auto-ban-triggered-'.$ban->key()); + + foreach ($this->ownerUids() as $uid) { + $this->alerts->addAlertToUser($uid, $alert, UiAlertDelivery::Queue, $presentation); + } + } catch (Throwable $error) { + $this->reportDeliveryFailure($error, $ban); + + return; + } + } + + /** + * @return list + */ + private function ownerUids(): array + { + $uids = $this->connection->fetchFirstColumn( + 'SELECT uid FROM user_account WHERE role = ? AND status = ?', + [UserRole::Owner->value, UserAccountStatus::Active->value], + ); + + return array_values(array_filter( + array_map(static fn (mixed $uid): string => is_string($uid) ? trim($uid) : '', $uids), + static fn (string $uid): bool => '' !== $uid, + )); + } + + private function reportDeliveryFailure(Throwable $error, ActiveAutoBan $ban): void + { + try { + $this->messageReporter?->report(Message::exception( + SecurityMessageCode::AUTO_BAN_ALERT_DELIVERY_DEGRADED, + SecurityMessageKey::AUTO_BAN_ALERT_DELIVERY_DEGRADED, + context: [ + 'operation' => 'notify_ban_triggered', + 'exception' => $error::class, + 'message' => $error->getMessage(), + 'active_ban_key' => $ban->key(), + 'subject_type' => $ban->subjectType(), + ], + ), ['component' => self::class]); + } catch (Throwable) { + } + } +} diff --git a/templates/backend/admin/security/auto-ban-detail.html.twig b/templates/backend/admin/security/auto-ban-detail.html.twig new file mode 100644 index 00000000..12529337 --- /dev/null +++ b/templates/backend/admin/security/auto-ban-detail.html.twig @@ -0,0 +1,64 @@ +{% extends '@backend/admin.html.twig' %} + +{% block title %}{{ 'admin.auto_bans.detail.title'|trans }}{% endblock %} + +{% block admin_sidebar %} + +{% endblock %} + +{% block admin_body %} + {% include '@backend/admin/partials/_page-header.html.twig' with { + eyebrow: 'admin.settings.security.title'|trans, + title: 'admin.auto_bans.detail.title'|trans, + } only %} + + {% set ban = auto_ban_detail.ban %} +
+

{{ ban.subject_label }}

+ + + + + + + +
{{ 'admin.auto_bans.columns.subject'|trans }}{{ ban.subject_type }}
{{ 'admin.auto_bans.columns.created_at'|trans }}{{ ban.created_at }}
{{ 'admin.auto_bans.columns.expires_at'|trans }}{{ ban.expires_at }}
{{ 'admin.auto_bans.columns.ttl'|trans }}{{ ban.ttl_seconds }}
+ {% if auto_ban_reset_mutable|default(false) %} +
+ + + +
+ {% endif %} +
+ +
+

{{ 'admin.auto_bans.detail.signals'|trans }}

+ {% if auto_ban_detail.signals is empty %} +

{{ 'admin.auto_bans.detail.no_signals'|trans }}

+ {% else %} + + + + + + + + + + + {% for signal in auto_ban_detail.signals %} + + + + + + + {% endfor %} + +
{{ 'admin.logs.columns.timestamp'|trans }}{{ 'admin.logs.columns.security_signal'|trans }}{{ 'admin.logs.columns.request'|trans }}{{ 'admin.logs.columns.http_status'|trans }}
{{ signal.occurred_at }}{{ signal.signal_type }}
{{ signal.reason_code }}
{{ signal.request_id }}
{{ signal.path }}
{{ signal.http_status|default('admin.logs.empty_value'|trans) }}
+ {% endif %} +
+{% endblock %} diff --git a/templates/backend/admin/security/auto-bans.html.twig b/templates/backend/admin/security/auto-bans.html.twig new file mode 100644 index 00000000..4ca8a301 --- /dev/null +++ b/templates/backend/admin/security/auto-bans.html.twig @@ -0,0 +1,43 @@ +{% extends '@backend/admin.html.twig' %} + +{% block title %}{{ 'admin.auto_bans.active.title'|trans }}{% endblock %} + +{% block admin_sidebar %} + +{% endblock %} + +{% block admin_body %} + {% include '@backend/admin/partials/_page-header.html.twig' with { + eyebrow: 'admin.settings.security.title'|trans, + title: 'admin.auto_bans.active.title'|trans, + } only %} + +
+ {% if active_auto_bans|default([]) is empty %} +

{{ 'admin.auto_bans.active.empty'|trans }}

+ {% else %} + + + + + + + + + + + {% for ban in active_auto_bans %} + + + + + + + {% endfor %} + +
{{ 'admin.auto_bans.columns.subject'|trans }}{{ 'admin.auto_bans.columns.created_at'|trans }}{{ 'admin.auto_bans.columns.expires_at'|trans }}{{ 'admin.auto_bans.columns.details'|trans }}
{{ ban.subject_type }}
{{ ban.subject_label }}
{{ ban.created_at }}{{ ban.expires_at }}{{ 'admin.auto_bans.active.open'|trans }}
+ {% endif %} +
+{% endblock %} diff --git a/templates/backend/admin/settings/section.html.twig b/templates/backend/admin/settings/section.html.twig index c8335364..7a70412d 100644 --- a/templates/backend/admin/settings/section.html.twig +++ b/templates/backend/admin/settings/section.html.twig @@ -60,5 +60,14 @@ class: 'system-settings-actions', } only %} {% endif %} + {% if settings_section == 'security' %} +

{{ 'admin.auto_bans.active.title'|trans }}

+ {% if auto_ban_enabled|default(false) %} + {{ 'admin.auto_bans.active.open_list'|trans }} + {% else %} + +

{{ 'admin.auto_bans.active.disabled'|trans }}

+ {% endif %} + {% endif %} {% endblock %} diff --git a/tests/Controller/ApiAdminOperationalControllerTest.php b/tests/Controller/ApiAdminOperationalControllerTest.php index 7cc3eaa8..eb62b440 100644 --- a/tests/Controller/ApiAdminOperationalControllerTest.php +++ b/tests/Controller/ApiAdminOperationalControllerTest.php @@ -14,6 +14,8 @@ use App\Core\Workflow\WorkflowResult; use App\Entity\ApiKey; use App\Security\ApiKeyStatus; +use App\Security\AutoBan\AutoBanStore; +use App\Security\AutoBan\AutoBanSubject; use App\Security\ApiKeyVault; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -54,6 +56,70 @@ public function testAdminOperationalEndpointsReturnBasicReadModels(): void } } + public function testAdminSecurityAutoBansAreOwnerGated(): void + { + $client = self::createClient(); + $adminKey = $this->createPlainApiKey('apiautobanadm'); + + $client->request('GET', '/api/v1/admin/security/auto-bans', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$adminKey, + ]); + + self::assertResponseStatusCodeSame(403); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame('admin.settings.security', $payload['error']['context']['feature']); + self::assertSame('feature_hidden', $payload['error']['context']['reason']); + } + + public function testAdminSecurityAutoBansListDetailAndResetAreExposedByApi(): void + { + $client = self::createClient(); + $plainKey = $this->createPlainApiKey('apiautobanown', ApiKeyStatus::ReadWrite, AccessLevel::OWNER); + $store = self::getContainer()->get(AutoBanStore::class); + self::assertInstanceOf(AutoBanStore::class, $store); + foreach ($store->activeBans() as $active) { + $store->reset($active->key()); + } + + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'api-auto-ban-visitor'); + $ban = $store->ban($subject, 3600, ['score' => 100, 'signal_count' => 2]); + self::assertNotNull($ban); + + try { + $client->request('GET', '/api/v1/admin/security/auto-bans', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame(1, $payload['meta']['count']); + self::assertSame('security_auto_ban', $payload['data'][0]['type']); + self::assertSame('/api/v1/admin/security/auto-bans/'.$ban->key(), $payload['data'][0]['links']['self']); + self::assertSame('/api/v1/admin/security/auto-bans/'.$ban->key().'/reset', $payload['data'][0]['links']['reset']); + + $client->request('GET', '/api/v1/admin/security/auto-bans/'.$ban->key(), server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame($ban->key(), $payload['data']['id']); + self::assertArrayHasKey('signals', $payload['data']['relationships']); + + $client->request('POST', '/api/v1/admin/security/auto-bans/'.$ban->key().'/reset', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + ]); + + self::assertResponseIsSuccessful(); + $payload = $this->jsonPayload($client->getResponse()->getContent()); + self::assertSame($ban->key(), $payload['data']['id']); + self::assertSame('auto_ban.reset_released', $payload['meta']['messages'][0]['code']); + self::assertNull($store->active($subject)); + } finally { + $store->reset($ban->key()); + } + } + public function testAdminLogsListSourcesAndSourceEntries(): void { $client = self::createClient(); @@ -442,16 +508,30 @@ public function testOpenApiIncludesAdminOperationalEndpoints(): void self::assertArrayHasKey($path, $payload['paths']); } + foreach (['/admin/security/auto-bans', '/admin/security/auto-bans/{key}', '/admin/security/auto-bans/{key}/reset'] as $path) { + self::assertArrayHasKey($path, $payload['paths']); + } + self::assertSame(['backend-admin', 'backend-admin-backups'], $payload['paths']['/admin/backups']['get']['tags']); self::assertSame(['backend-admin', 'backend-admin-logs'], $payload['paths']['/admin/logs']['get']['tags']); self::assertSame(['backend-admin', 'backend-admin-operations'], $payload['paths']['/admin/operations']['get']['tags']); self::assertSame(['backend-admin', 'backend-admin-scheduler'], $payload['paths']['/admin/scheduler']['get']['tags']); + self::assertSame(['backend-admin', 'backend-admin-security'], $payload['paths']['/admin/security/auto-bans']['get']['tags']); + self::assertSame('resetAdminSecurityAutoBan', $payload['paths']['/admin/security/auto-bans/{key}/reset']['post']['operationId']); + self::assertSame(AccessLevel::ADMIN, $payload['paths']['/admin/security/auto-bans']['get']['x-access']['required_access_level']); 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( ['application', 'message', 'audit', 'access', 'security_signal'], $payload['paths']['/admin/logs/{log}']['get']['parameters'][0]['schema']['enum'], ); + self::assertContains([ + 'name' => 'backend-admin-security', + 'summary' => 'Backend Admin Security', + 'description' => 'Administrative security configuration, signals, and auto-ban resources.', + 'parent' => 'backend-admin', + 'kind' => 'nav', + ], $payload['tags']); self::assertContains([ 'name' => 'backend-admin-operations', 'summary' => 'Backend Admin Operations', @@ -461,9 +541,9 @@ public function testOpenApiIncludesAdminOperationalEndpoints(): 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( diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index 4ee224d4..c2de1119 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -23,6 +23,8 @@ use App\Core\Workflow\WorkflowResult; use App\Entity\AclGroup; use App\Entity\ExtensionPackage; +use App\Security\AutoBan\AutoBanPolicy; +use App\Security\AutoBan\AutoBanStore; use App\Security\RateLimit\RateLimitPolicyCatalogue; use App\Security\UserAccountStatus; use App\Security\UserFlowConfig; @@ -1166,6 +1168,22 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void 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"]'); + self::assertSelectorExists(sprintf('input[name="%s"]', AutoBanPolicy::ENABLED_KEY)); + self::assertSelectorExists(sprintf('select[name="%s"]', AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY)); + self::assertSelectorExists(sprintf('input[name="%s"]', AutoBanPolicy::SCORE_THRESHOLD_KEY)); + self::assertSelectorExists(sprintf('input[name="%s"]', AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY)); + self::assertSelectorExists('a[href="/admin/security/auto-bans"]'); + + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set(AutoBanPolicy::ENABLED_KEY, false, ConfigValueType::Boolean); + $client->request('GET', '/admin/settings/security'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('button.system-button[disabled]'); + self::assertSelectorNotExists('a[href="/admin/security/auto-bans"]'); + self::assertStringContainsString('Auto-ban enforcement is disabled.', (string) $client->getResponse()->getContent()); + $config->set(AutoBanPolicy::ENABLED_KEY, true, ConfigValueType::Boolean); $client->request('GET', '/admin/settings/logging'); @@ -1176,8 +1194,6 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void 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); $config->set('statistics.geoip.maxmind.license_key', '', ConfigValueType::String, sensitive: true); $this->loginUserWithLevel($client, AccessLevel::OWNER); @@ -1229,6 +1245,24 @@ public function testAdminSettingsRoutesRenderThroughRegistry(): void self::assertStringNotContainsString('$_SERVER', (string) $client->getResponse()->getContent()); } + public function testAutoBanListRendersAsDedicatedSecurityView(): void + { + $client = self::createClient(); + $this->loginUserWithLevel($client, AccessLevel::OWNER); + $store = self::getContainer()->get(AutoBanStore::class); + self::assertInstanceOf(AutoBanStore::class, $store); + foreach ($store->activeBans() as $ban) { + $store->reset($ban->key()); + } + + $client->request('GET', '/admin/security/auto-bans'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Active auto-bans'); + self::assertSelectorTextContains('.system-muted', 'No active auto-bans.'); + self::assertSelectorExists('a[href="/admin/settings/security"]'); + } + public function testSecuritySettingsSectionIsHiddenAndRejectsPostsForDelegatedAdmins(): void { $client = self::createClient(); @@ -1238,6 +1272,10 @@ public function testSecuritySettingsSectionIsHiddenAndRejectsPostsForDelegatedAd self::assertResponseStatusCodeSame(401); + $client->request('GET', '/admin/security/auto-bans'); + + self::assertResponseStatusCodeSame(401); + $client->request('POST', '/admin/settings/security', [ '_form_id' => 'admin-settings-security', '_csrf_token' => 'direct-post', diff --git a/tests/Core/Config/CoreSettingsFormHandlerTest.php b/tests/Core/Config/CoreSettingsFormHandlerTest.php index 07f3bc22..d1fa65e4 100644 --- a/tests/Core/Config/CoreSettingsFormHandlerTest.php +++ b/tests/Core/Config/CoreSettingsFormHandlerTest.php @@ -16,6 +16,7 @@ use App\Form\FormSubmissionHandler; use App\Localization\TranslationLanguageCatalog; use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\AutoBan\AutoBanPolicy; use App\Security\RateLimit\RateLimitPolicyCatalogue; use App\Security\RateLimit\RateLimitProfile; use App\View\SystemPackageMetadataProvider; @@ -96,6 +97,10 @@ public function testItInvalidatesSuspiciousProbePatternCacheWhenSecuritySettings 'security.captcha.enabled' => '0', 'security.captcha.provider' => 'none', RateLimitPolicyCatalogue::MODE_KEY => RateLimitProfile::Strict->value, + AutoBanPolicy::ENABLED_KEY => '1', + AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY => (string) AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, + AutoBanPolicy::SCORE_THRESHOLD_KEY => (string) AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, + AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY => '1', ConfigAuditLogPolicy::ENABLED_KEY => '1', ConfigAuditLogPolicy::EVENTS_KEY => ConfigAuditLogPolicy::DEFAULT_CATEGORIES, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY => '7', @@ -122,6 +127,10 @@ public function testItRejectsInvalidRateLimitModes(): void 'security.captcha.enabled' => '0', 'security.captcha.provider' => 'none', RateLimitPolicyCatalogue::MODE_KEY => 'forever', + AutoBanPolicy::ENABLED_KEY => '1', + AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY => (string) AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, + AutoBanPolicy::SCORE_THRESHOLD_KEY => (string) AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, + AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY => '1', ConfigAuditLogPolicy::ENABLED_KEY => '1', ConfigAuditLogPolicy::EVENTS_KEY => ConfigAuditLogPolicy::DEFAULT_CATEGORIES, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY => '7', diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index 0fde14f6..9a03eca9 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -17,6 +17,7 @@ use App\Form\FormInputType; use App\Localization\TranslationLanguageCatalog; use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\AutoBan\AutoBanPolicy; use App\Security\RateLimit\RateLimitPolicyCatalogue; use App\Security\RateLimit\RateLimitProfile; use App\Security\UserFlowConfig; @@ -65,6 +66,10 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void 'security.captcha.provider', 'security.captcha.preview', RateLimitPolicyCatalogue::MODE_KEY, + AutoBanPolicy::ENABLED_KEY, + AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, + AutoBanPolicy::SCORE_THRESHOLD_KEY, + AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY, ConfigAuditLogPolicy::ENABLED_KEY, ConfigAuditLogPolicy::EVENTS_KEY, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, @@ -80,11 +85,16 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void 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::assertTrue($security[4]->defaultValue()); + self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, $security[5]->defaultValue()); + self::assertSame(FormInputType::Select, $security[5]->formField()->inputType()); + self::assertSame(AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, $security[6]->defaultValue()); + self::assertTrue($security[7]->defaultValue()); + self::assertSame(FormInputType::MultiSelect, $security[9]->formField()->inputType()); + self::assertSame(ConfigAuditLogPolicy::DEFAULT_CATEGORIES, $security[9]->defaultValue()); + self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $security[10]->defaultValue()); + self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $security[11]->defaultValue()); + self::assertSame(FormInputType::Textarea, $security[11]->formField()->inputType()); self::assertSame([ DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, @@ -143,6 +153,10 @@ public function testItExposesPersistedDefaultsForRuntimeConfigFallbacks(): void 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::assertTrue($provider->defaultValue(AutoBanPolicy::ENABLED_KEY)); + self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, $provider->defaultValue(AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY)); + self::assertSame(AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, $provider->defaultValue(AutoBanPolicy::SCORE_THRESHOLD_KEY)); + self::assertTrue($provider->defaultValue(AutoBanPolicy::NEW_BAN_OWNER_ALERTS_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/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php b/tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php new file mode 100644 index 00000000..110f9630 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php @@ -0,0 +1,150 @@ +connection(); + $connection->insert('user_account', [ + 'uid' => 'owner-uid', + 'role' => UserRole::Owner->value, + 'status' => UserAccountStatus::Active->value, + ]); + $connection->insert('user_account', [ + 'uid' => 'admin-uid', + 'role' => UserRole::Admin->value, + 'status' => UserAccountStatus::Active->value, + ]); + $alerts = new RecordingAutoBanAlertDispatcher(); + $notifier = new AutoBanOwnerAlertNotifier(new AutoBanPolicy(new Config($connection)), $connection, $alerts); + $ban = $this->ban(); + + $notifier->notifyBanTriggered($ban); + + self::assertCount(1, $alerts->userAlerts); + self::assertSame('owner-uid', $alerts->userAlerts[0]['user']); + self::assertSame(UiAlertDelivery::Queue, $alerts->userAlerts[0]['delivery']); + self::assertInstanceOf(UiAlertTranslation::class, $alerts->userAlerts[0]['alert']); + self::assertSame('admin.auto_bans.alerts.triggered', $alerts->userAlerts[0]['alert']->translationKey()); + self::assertSame('hidden', $alerts->userAlerts[0]['presentation']?->mode()); + self::assertSame('auto-ban-triggered-'.$ban->key(), $alerts->userAlerts[0]['presentation']?->id()); + self::assertSame([ + ['label' => 'Review', 'href' => '/admin/security/auto-bans'], + ], $alerts->userAlerts[0]['presentation']?->actions()); + } + + public function testItSkipsOwnerAlertsWhenDeliveryIsDisabled(): void + { + $connection = $this->connection(); + $connection->insert('user_account', [ + 'uid' => 'owner-uid', + 'role' => UserRole::Owner->value, + 'status' => UserAccountStatus::Active->value, + ]); + $config = new Config($connection); + $config->set(AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY, false, ConfigValueType::Boolean); + $alerts = new RecordingAutoBanAlertDispatcher(); + $notifier = new AutoBanOwnerAlertNotifier(new AutoBanPolicy($config), $connection, $alerts); + + $notifier->notifyBanTriggered($this->ban()); + + self::assertSame([], $alerts->userAlerts); + } + + private function ban(): ActiveAutoBan + { + return new ActiveAutoBan( + 'abc123abc123abc123abc123abc123abc123abcd', + 'visitor', + 'visitor-alert', + DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', '2026-06-18 12:00:00'), + DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', '2026-06-18 13:00:00'), + 3600, + ); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE user_account (uid VARCHAR(36) PRIMARY KEY NOT NULL, role VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL)'); + + return $connection; + } +} + +final class RecordingAutoBanAlertDispatcher implements UiAlertDispatcherInterface +{ + /** + * @var list + */ + public array $userAlerts = []; + + public function addAlert( + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Direct, + ?UiAlertPresentation $presentation = null, + ): bool { + return true; + } + + public function addAlertToTopic( + string $topic, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool { + return true; + } + + public function addAlertToUser( + UserAccount|UserInterface|string $user, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool { + $this->userAlerts[] = [ + 'user' => is_string($user) ? $user : $user->getUserIdentifier(), + 'alert' => $alert, + 'delivery' => $delivery, + 'presentation' => $presentation, + ]; + + return true; + } + + public function addAlertToSession( + SessionInterface|string $session, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool { + return true; + } +} diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index 13c73baf..d5446444 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -654,6 +654,29 @@ admin: context: 'Kontext' no_context: 'Es ist kein strukturierter Kontext verfügbar.' raw: 'Rohzeile' + auto_bans: + active: + title: 'Aktive Auto-Bans' + empty: 'Keine aktiven Auto-Bans.' + open: 'Prüfen' + open_list: 'Aktive Auto-Bans öffnen' + disabled: 'Auto-Ban-Enforcement ist deaktiviert. Bestehende TTL-States bleiben bis zum Ablauf erhalten, werden aber nicht durchgesetzt.' + columns: + subject: 'Subjekt' + created_at: 'Erstellt' + expires_at: 'Läuft ab' + ttl: 'TTL-Sekunden' + details: 'Details' + detail: + title: 'Auto-Ban-Detail' + signals: 'Zugehörige Security-Signale' + no_signals: 'Für diesen aktiven Bann sind keine aufbewahrten Signale verfügbar.' + reset: + submit: 'Auto-Ban zurücksetzen' + saved: 'Auto-Ban zurückgesetzt.' + failed: 'Auto-Ban konnte nicht zurückgesetzt werden.' + alerts: + triggered: 'Auto-Ban für %subject% erstellt.' statistics: title: 'Statistiken' access_title: 'Zugriffsstatistiken' @@ -778,6 +801,18 @@ admin: label: 'Captcha-Vorschau' rate_limit_mode: label: 'Ratenbegrenzung' + auto_ban_enabled: + label: 'Auto-Ban aktivieren' + help: 'Blockiert wiederholt verdächtige Visitor/IP-Aktivität vorübergehend. Trusted User bleiben ausgenommen.' + auto_ban_trusted_access_level: + label: 'Trusted-User-Level' + help: 'Registrierte Benutzer ab diesem Access-Level werden nie automatisch gebannt.' + auto_ban_score_threshold: + label: 'Auto-Ban-Score-Schwelle' + help: 'Score, der innerhalb einer Stunde erreicht werden muss, bevor ein neuer Visitor-Bann entstehen kann. Reine IP-Banns verwenden intern eine höhere Schwelle.' + auto_ban_new_ban_owner_alerts: + label: 'Alerts für neue Auto-Bans aktivieren' + help: 'Stellt Owner-Accounts eine versteckte Warnung zu, wenn ein neuer aktiver Auto-Ban erzeugt wird.' audit_enabled: label: 'Audit-Logging aktivieren' audit_events: @@ -796,6 +831,11 @@ admin: standard: 'Standard' strict: 'Streng' panic: 'Panik' + access_level: + manager: 'Manager' + director: 'Director' + admin: 'Admin' + owner: 'Owner' audit: authentication: 'Authentifizierungsereignisse' backend_actions: 'Backend-Wartungsaktionen' @@ -944,6 +984,18 @@ admin: label: 'Captcha-Vorschau' rate_limit_mode: label: 'Ratenbegrenzung' + auto_ban_enabled: + label: 'Auto-Ban aktivieren' + help: 'Blockiert wiederholt verdächtige Visitor/IP-Aktivität vorübergehend. Trusted User bleiben ausgenommen.' + auto_ban_trusted_access_level: + label: 'Trusted-User-Level' + help: 'Registrierte Benutzer ab diesem Access-Level werden nie automatisch gebannt.' + auto_ban_score_threshold: + label: 'Auto-Ban-Score-Schwelle' + help: 'Score, der innerhalb einer Stunde erreicht werden muss, bevor ein neuer Visitor-Bann entstehen kann. Reine IP-Banns verwenden intern eine höhere Schwelle.' + auto_ban_new_ban_owner_alerts: + label: 'Alerts für neue Auto-Bans aktivieren' + help: 'Stellt Owner-Accounts eine versteckte Warnung zu, wenn ein neuer aktiver Auto-Ban erzeugt wird.' audit_enabled: label: 'Audit-Logging aktivieren' audit_events: @@ -1015,6 +1067,11 @@ admin: standard: 'Standard' strict: 'Streng' panic: 'Panik' + access_level: + manager: 'Manager' + director: 'Director' + admin: 'Admin' + owner: 'Owner' audit: authentication: 'Authentifizierungsereignisse' backend_actions: 'Backend-Wartungsaktionen' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index 2fecffd0..04662947 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -654,6 +654,29 @@ admin: context: 'Context' no_context: 'No structured context is available.' raw: 'Raw line' + auto_bans: + active: + title: 'Active auto-bans' + empty: 'No active auto-bans.' + open: 'Review' + open_list: 'Open active auto-bans' + disabled: 'Auto-ban enforcement is disabled. Existing TTL states are retained until expiry but are not enforced.' + columns: + subject: 'Subject' + created_at: 'Created' + expires_at: 'Expires' + ttl: 'TTL seconds' + details: 'Details' + detail: + title: 'Auto-ban detail' + signals: 'Related security signals' + no_signals: 'No retained signals are available for this active ban.' + reset: + submit: 'Reset auto-ban' + saved: 'Auto-ban reset.' + failed: 'Auto-ban could not be reset.' + alerts: + triggered: 'Auto-ban created for %subject%.' statistics: title: 'Statistics' access_title: 'Access statistics' @@ -778,6 +801,18 @@ admin: label: 'Captcha preview' rate_limit_mode: label: 'Rate limiting' + auto_ban_enabled: + label: 'Enable auto-ban' + help: 'Temporarily blocks repeated suspicious Visitor/IP activity. Trusted users remain exempt.' + auto_ban_trusted_access_level: + label: 'Trusted user level' + help: 'Registered users at or above this access level are never auto-banned.' + auto_ban_score_threshold: + label: 'Auto-ban score threshold' + help: 'Score required within one hour before a new Visitor ban can be created. IP-only bans use a higher internal threshold.' + auto_ban_new_ban_owner_alerts: + label: 'Enable alerts for newly decided auto-bans' + help: 'Queues a hidden warning for Owner accounts when a new active auto-ban is created.' audit_enabled: label: 'Enable audit logging' audit_events: @@ -796,6 +831,11 @@ admin: standard: 'Standard' strict: 'Strict' panic: 'Panic' + access_level: + manager: 'Manager' + director: 'Director' + admin: 'Admin' + owner: 'Owner' audit: authentication: 'Authentication events' backend_actions: 'Backend maintenance actions' @@ -944,6 +984,18 @@ admin: label: 'Captcha preview' rate_limit_mode: label: 'Rate limiting' + auto_ban_enabled: + label: 'Enable auto-ban' + help: 'Temporarily blocks repeated suspicious Visitor/IP activity. Trusted users remain exempt.' + auto_ban_trusted_access_level: + label: 'Trusted user level' + help: 'Registered users at or above this access level are never auto-banned.' + auto_ban_score_threshold: + label: 'Auto-ban score threshold' + help: 'Score required within one hour before a new Visitor ban can be created. IP-only bans use a higher internal threshold.' + auto_ban_new_ban_owner_alerts: + label: 'Enable alerts for newly decided auto-bans' + help: 'Queues a hidden warning for Owner accounts when a new active auto-ban is created.' audit_enabled: label: 'Enable audit logging' audit_events: @@ -1015,6 +1067,11 @@ admin: standard: 'Standard' strict: 'Strict' panic: 'Panic' + access_level: + manager: 'Manager' + director: 'Director' + admin: 'Admin' + owner: 'Owner' audit: authentication: 'Authentication events' backend_actions: 'Backend maintenance actions' From aa6262da687182f879db8214bf81d3e01cfc2e8c Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 13:09:37 +0200 Subject: [PATCH 06/55] Document auto-ban review policy --- dev/CLASSMAP.md | 7 ++++--- dev/WORKLOG.md | 8 ++++++++ dev/draft/security-hardening/auto-ban.md | 15 +++++++++------ dev/manual/security-guard-snippets.md | 17 ++++++++++++++++- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index c1d07982..5a03a04d 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -199,10 +199,11 @@ | Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, stream archives without materializing them in memory, write atomically to `var/geoip2`, reject unsafe archive member paths, preserve the previous database on failure where possible, bound stored location labels, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php`, `tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel and the database lookup projection; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, package install verification, package lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | -| Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates, audits, and records a high-risk passive security signal when established sessions reappear with a different visitor signal so copied session cookies do not stay usable. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | -| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for later rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, and cached configurable suspicious probe path patterns while avoiding non-existent contact/captcha path assumptions, assigns symbolic action costs without enforcing limits, and records clear passive signals for high-signal probes and unsafe prefetch attempts. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php` | +| Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | +| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\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 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, records source-subject Visitor/IP signals for high-signal probes and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/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/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, enforces active bans after trusted session/API context but before ordinary rate-limit buckets, returns forced bare `403` with `Retry-After` and request ID, lets trusted users and recovery login bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-gated `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows filtered retained signal detail, and records Owner reset signals that clear active cache state and invalidate earlier scoring evidence. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | +| Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 92e98e6f..a5969810 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -89,6 +89,14 @@ - Tightened review-readiness decisions: scoreable signals are persisted per evaluated source subject so IP scoring uses indexed subject reads instead of JSON context, one evaluation creates at most one active ban with Visitor preferred over IP, active-ban list rendering uses a cache-backed index while per-subject TTL state remains authoritative, and first-slice auto-ban settings/manual resets are Owner-gated. - Recorded the auto-ban performance policy: score aggregation is triggered only after a scoreable `security_signal_event` write and reuses that DB path for indexed Visitor/IP lookups; ordinary non-signal requests perform only the active-ban cache check and must not start database score queries. - Verification: documentation-only preparation slice; focused Markdown lint passed. +- Implemented the first auto-ban slice: scoreable Security signals for suspicious error hits, probes, failed-auth attempts, and session/visitor mismatches now feed Visitor/IP subject scoring only from the signal write path; active bans use cache-backed TTL state with an Admin index, Visitor-before-IP selection, reset cutoffs, retained trigger/reset signal context, and early request enforcement after trusted context resolution. +- Added Owner-gated Security settings for enablement, trusted-user level, and score threshold; Security settings link to a dedicated active-ban list instead of embedding the list in the settings registry, and the link renders disabled when auto-ban is disabled. Disabling auto-ban now stops score evaluation and enforcement while existing TTL states remain until expiry. +- Added the documented GET-only plural recovery-login render alias and RequestPathResolver coverage so localized `/users/login?bypass=1` recovery renders can bypass active source bans without adding a POST/login-check surface. +- Normalized current-time reads in the auto-ban/session-security path through Symfony Clock and disabled kernel-triggered auto-ban evaluation/enforcement by default in the Symfony `test` environment to prevent broad controller suites from poisoning shared active-ban cache state with intentional error-response tests. +- Replaced throw-based auto-ban payload timestamp parsing with bounded `createFromFormat()` parsing and routed auto-ban storage/evaluation degradation through Security Message-layer diagnostics while preserving fail-open behavior. +- Added configurable hidden Owner alerts for newly decided bans, linked alert actions directly to the active-ban list, added success/error alerts for manual ban release and failed settings saves, and routed alert-delivery degradation through Security Message-layer diagnostics. +- Registered `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing API endpoint registry. Browser and API auto-ban review/reset surfaces use the existing non-configurable `admin.settings.security` ACL gate, so delegated non-Owner admins cannot access the ban list. +- Verification: `php -l` on changed PHP entry points passed; `php bin/console lint:container` passed; focused AutoBan/API/settings/message PHPUnit groups passed; full `php bin/phpunit` passed with 1631 tests and 10708 assertions; `bin/lint --diff` plus explicit lint for new auto-ban files passed. ### 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 0e5a59dc..a2d8c2d6 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -33,18 +33,20 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 3. Treat suspicious probe responses and their generic `400` status as one connected risk action. The probe signal is the high-confidence source; the response-status signal may add context but must not double-count the same request as two independent actions. 4. Add an auto-ban policy service that aggregates retained, non-reset Security signals over a one-hour scoring window by Visitor ID first and by stable client IP bucket/HMAC as a secondary source subject. Score aggregation runs only from the qualifying signal write path, reusing the active database connection after signal persistence; ordinary requests that do not create a scoreable signal must not perform database score lookups. 5. Ensure scoreable request signals are persisted for every evaluated source subject, normally Visitor ID and IP bucket, with shared request/correlation context so Visitor and IP scoring can use indexed `subject_type`/`subject_identifier` reads instead of portable-unsafe JSON filtering. -6. Add bounded Owner-gated Config/Settings defaults through the existing settings registry/default provider for auto-ban enablement, trusted-user minimum access level, score threshold, and any required bounded policy constants so missing databases use seeded defaults and do not cause Doctrine/DBAL throws during setup or degraded states. +6. Add bounded Owner-gated Config/Settings defaults through the existing settings registry/default provider for auto-ban enablement, trusted-user minimum access level, score threshold, Owner alert delivery for newly decided bans, and any required bounded policy constants so missing databases use seeded defaults and do not cause Doctrine/DBAL throws during setup or degraded states. 7. Add cache-flock-backed active ban state with TTL plus a cache-backed active-ban index for Admin list rendering. The ban store and index must be fail-open when cache/lock storage is unavailable and must never create an invisible permanent block. 8. Emit a persistent `security_signal_event` record when a ban is triggered, including whether the effective subject was `visitor` or `ip`, the TTL/escalation context, score summary, and safe references needed for Admin review without exposing raw IPs, raw visitor-cookie tokens, headers, secrets, or raw credentials. 9. Emit a Security signal when an Owner manually resets a ban. Reset signals invalidate earlier retained signals for that same subject and subject type for future score and escalation calculations, so the visitor/IP starts at zero after reset. 10. Add enforcement early enough to run before controller/error-page rendering and before rate-limit buckets are consumed, but late enough that authenticated trusted users and trusted-user-owned API keys have been resolved and can bypass active Visitor/IP bans. Active temporary bans return the shared forced bare `403 Forbidden` response with `Retry-After`, generic message, and safe Request ID only. -11. Add Owner-gated Security settings UI fields for auto-ban enablement, trusted-user minimum access level, and score threshold. -12. Add the active-ban Admin list with subject type, safe subject label, created timestamp, TTL expiry, and detail link. +11. Add Owner-gated Security settings UI fields for auto-ban enablement, trusted-user minimum access level, score threshold, and newly decided ban alerts. +12. Add the active-ban Admin list with subject type, safe subject label, created timestamp, TTL expiry, and detail link. The list and detail views use the existing non-configurable `admin.settings.security` ACL gate instead of a separate auto-ban gate. 13. Add the ban detail page with filtered Security signals explaining the decision and an Owner-gated manual reset button. +14. Register Admin API endpoints for listing active bans, reading one ban with retained signal context, and resetting one active ban under `/api/v1/admin/security/auto-bans`, using the same `admin.settings.security` ACL gate as the browser UI. ## Public interfaces and data decisions - Auto-ban is enabled by default through a bounded Security setting. +- Newly decided ban owner alerts are enabled by default through a bounded Security setting. Alerts use hidden warning delivery and link directly to the active-ban list so Owners can review current state before drilling into details. - Primary source scoring is by Visitor ID. Stable client IP evidence is evaluated separately to reduce header/cookie mutation bypasses, but IP-only thresholds use a fixed multiplier above the Visitor threshold so legitimate visitors behind NAT or untrusted proxies are less likely to be blocked. User accounts and API keys are context for trusted-user bypass decisions, not auto-ban subjects. - The initial scoring window is one hour. - First score defaults use a Visitor threshold of `100`, an IP threshold multiplier of `2` for an effective IP threshold of `200`, and a minimum of two qualifying signals before any ban can be created. @@ -108,7 +110,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test subscriber ordering against existing probe, API authentication, browser session, ordinary rate-limit, and error-rendering hooks. - Test recovery-login bypass render despite active Visitor/IP bans, dedicated recovery-login bucket behavior, CSRF/credential/failure accounting, audit logging, and post-login re-evaluation. - Test bare browser `403` response shape, `Retry-After`, request ID, `no-store`, and redaction. -- Test Admin active-ban list, detail filtering, and manual reset permissions/audit/signal creation. +- Test Admin active-ban list, detail filtering, API list/detail/reset endpoints, and manual reset permissions/audit/signal creation. +- Test that delegated non-Owner admins cannot access active-ban browser or API surfaces through the `admin.settings.security` ACL gate. - Test settings descriptors, default provider values, validation bounds, translations, and missing-database defaults. - Test migration/schema only if this branch changes existing Security signal fields; the preferred implementation should avoid new ban tables. - Test `php bin/console lint:container` after service/config changes. @@ -117,8 +120,8 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Update Security policy defaults with final score weights, threshold default, multiplier, TTL escalation, trusted-user default, and response semantics. - Update Security settings documentation/manual notes once the UI lands. -- Update Admin/security diagnostics notes for active-ban list, detail review, and manual reset semantics. -- Update class map for the score catalogue, policy service, cache-flock store, enforcement subscriber, settings descriptors, Admin routes/controllers, and tests. +- Update Admin/security diagnostics notes for active-ban list, detail review, owner alerts, API endpoints, and manual reset semantics. +- Update class map for the score catalogue, policy service, cache-flock store, enforcement subscriber, settings descriptors, Admin routes/controllers/API handlers, and tests. - Record threshold and false-positive assumptions in the worklog. - Complete the Security PR-readiness checklist from the master hardening plan before opening the PR. diff --git a/dev/manual/security-guard-snippets.md b/dev/manual/security-guard-snippets.md index 09ba2efd..bc144208 100644 --- a/dev/manual/security-guard-snippets.md +++ b/dev/manual/security-guard-snippets.md @@ -1,7 +1,7 @@ # Security guard snippets > **Status**: Draft -> **Updated**: 2026-05-31 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Collect implementation notes for filesystem, package, operation, and configuration guards before they become formal security documentation. @@ -56,6 +56,21 @@ Do not downgrade `failed` or `blocked` queues to `requires_review`. - Do not rotate `APP_SECRET` during normal maintenance. Treat a changed `APP_SECRET` as an emergency response to a confirmed or likely compromise because it invalidates secret-derived hashes and encrypted values. - The owner recovery flow after an `APP_SECRET` change is a failsafe only. Prefer direct operator recovery through `bin/setup --reset-password` when CLI access is available. +## Auto-ban guardrails + +Auto-ban enforcement is temporary, source-subject based, and fail-open: + +- Score aggregation runs only after a scoreable `security_signal_event` write. Ordinary requests perform only the active cache-state check. +- Active ban state lives in cache-backed TTL entries with a cache-backed Admin index. Retained Security signals explain trigger and reset history; there is no durable ban table. +- Visitor ID is the primary source subject. IP bucket/HMAC is evaluated separately with a laxer threshold multiplier. +- Trusted registered users at or above the configured trusted level, trusted-user-owned API keys, and the recovery login render path must bypass active Visitor/IP bans. +- Owner review surfaces for active bans use the non-configurable `admin.settings.security` ACL gate. The browser list/detail views and `/api/v1/admin/security/auto-bans` endpoints must reject delegated non-Owner admins. +- Newly decided ban alerts are configurable and enabled by default. When enabled, active Owner accounts receive a hidden warning with an action link to the active-ban list. +- Disabling auto-ban stops score evaluation and active-ban enforcement immediately. Existing TTL cache entries may remain until they expire, but they must not block requests while the feature is disabled. +- The Symfony `test` environment disables kernel-triggered auto-ban evaluation and enforcement unless a request explicitly opts in with `X-Auto-Ban-Testing: 1`, so broad controller suites that intentionally render many error responses do not poison shared test cache state. +- Active ban responses use the forced bare `403` path with `Retry-After`, `no-store`, a generic message, and a safe Request ID only. +- Manual reset clears active cache state, records a reset Security signal, and returns a success/error alert for the release workflow. Score and escalation queries ignore earlier evidence for the same subject after that reset. + ## Web server notes - Apache fallback redirects must preserve base paths. From 16b4d45412643522ccf7392a1e0dfe936cb0e27f Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 13:17:23 +0200 Subject: [PATCH 07/55] Keep recovery login on user route --- dev/WORKLOG.md | 4 ++-- dev/draft/0.2.x-SecurityHardeningPlan.md | 2 +- dev/draft/security-hardening/auto-ban.md | 2 +- dev/draft/security-hardening/policy-defaults.md | 2 +- dev/draft/security-hardening/rate-enforcement.md | 4 ++-- src/Controller/SecurityController.php | 2 -- src/Core/Routing/RequestPathResolver.php | 2 +- src/Security/Abuse/RequestIntentClassifier.php | 4 ++-- tests/Core/Routing/RequestPathResolverTest.php | 6 +++--- tests/Security/Abuse/RequestIntentClassifierTest.php | 4 ++-- tests/Security/AutoBan/AutoBanRequestSubscriberTest.php | 2 +- tests/Security/RateLimit/RateLimitEnforcerTest.php | 4 ++-- 12 files changed, 18 insertions(+), 20 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index a5969810..76b95b91 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -84,14 +84,14 @@ ### 2026-06-18 feat-security-auto-ban - Started the auto-ban preparation branch after `feat-security-rate-enforcement` merged and archived the completed rate-enforcement branch notes into `dev/WORKLOG_HISTORY.md`. - Recorded the auto-ban response decision: active temporary bans use the shared browser error renderer's forced bare response path with `403 Forbidden`, `Retry-After` when the TTL is known, `Cache-Control: no-store`, the safe Request ID, and the generic bare context `Request blocked due to suspicious activity. retry-after: ` without exposing score, rule, subject, IP, or signal internals. -- Updated the auto-ban plan and Security policy defaults for the score-based implementation: suspicious `400`/`403`/`404`/`429` signals feed a one-hour global score, Visitor ID is primary, stable IP scoring is secondary through a laxer threshold multiplier, active bans use cache-flock TTL state, TTLs escalate `1h`/`3h`/`24h`/`7d`, persistent ban-trigger and reset `security_signal_event` records drive escalation and reset cutoffs, threshold changes affect only future ban decisions, trusted registered users default to level `6`/`MANAGER` and are never auto-banned, setup/database degradation fails open, and the resolver-matched `/{LANG}/users/login?bypass=1` recovery-login render path remains reachable. +- Updated the auto-ban plan and Security policy defaults for the score-based implementation: suspicious `400`/`403`/`404`/`429` signals feed a one-hour global score, Visitor ID is primary, stable IP scoring is secondary through a laxer threshold multiplier, active bans use cache-flock TTL state, TTLs escalate `1h`/`3h`/`24h`/`7d`, persistent ban-trigger and reset `security_signal_event` records drive escalation and reset cutoffs, threshold changes affect only future ban decisions, trusted registered users default to level `6`/`MANAGER` and are never auto-banned, setup/database degradation fails open, and the resolver-matched `/user/login?bypass=1` recovery-login render path remains reachable. - Clarified the first score defaults and enforcement ordering: Visitor threshold `100`, IP threshold `x2`, minimum two qualifying signals, error-hit weight `7`, probe/session-copy weight `100`, failed-auth weight `10`; API keys are trusted-user context rather than auto-ban subjects, and active Visitor/IP bans must resolve before error pages or rate-limit bucket consumption but after trusted-user/API-key context can bypass them. - Tightened review-readiness decisions: scoreable signals are persisted per evaluated source subject so IP scoring uses indexed subject reads instead of JSON context, one evaluation creates at most one active ban with Visitor preferred over IP, active-ban list rendering uses a cache-backed index while per-subject TTL state remains authoritative, and first-slice auto-ban settings/manual resets are Owner-gated. - Recorded the auto-ban performance policy: score aggregation is triggered only after a scoreable `security_signal_event` write and reuses that DB path for indexed Visitor/IP lookups; ordinary non-signal requests perform only the active-ban cache check and must not start database score queries. - Verification: documentation-only preparation slice; focused Markdown lint passed. - Implemented the first auto-ban slice: scoreable Security signals for suspicious error hits, probes, failed-auth attempts, and session/visitor mismatches now feed Visitor/IP subject scoring only from the signal write path; active bans use cache-backed TTL state with an Admin index, Visitor-before-IP selection, reset cutoffs, retained trigger/reset signal context, and early request enforcement after trusted context resolution. - Added Owner-gated Security settings for enablement, trusted-user level, and score threshold; Security settings link to a dedicated active-ban list instead of embedding the list in the settings registry, and the link renders disabled when auto-ban is disabled. Disabling auto-ban now stops score evaluation and enforcement while existing TTL states remain until expiry. -- Added the documented GET-only plural recovery-login render alias and RequestPathResolver coverage so localized `/users/login?bypass=1` recovery renders can bypass active source bans without adding a POST/login-check surface. +- Kept recovery-login bypass matching on the established `/user/login?bypass=1` user-workflow route and RequestPathResolver coverage for locale-prefixed `/user/login` path matching without adding a plural `/users` runtime surface. - Normalized current-time reads in the auto-ban/session-security path through Symfony Clock and disabled kernel-triggered auto-ban evaluation/enforcement by default in the Symfony `test` environment to prevent broad controller suites from poisoning shared active-ban cache state with intentional error-response tests. - Replaced throw-based auto-ban payload timestamp parsing with bounded `createFromFormat()` parsing and routed auto-ban storage/evaluation degradation through Security Message-layer diagnostics while preserving fail-open behavior. - Added configurable hidden Owner alerts for newly decided bans, linked alert actions directly to the active-ban list, added success/error alerts for manual ban release and failed settings saves, and routed alert-delivery degradation through Security Message-layer diagnostics. diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md index 68dac842..39319d7d 100644 --- a/dev/draft/0.2.x-SecurityHardeningPlan.md +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -322,7 +322,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. Auto-ban must correlate the probe signal and its `400` response so one request is not double-counted. -- The `/{LANG}/users/login?bypass=1` recovery login render path, resolved through the shared `RequestPathResolver`, must remain reachable even when the current Visitor ID or IP bucket is banned; it uses a dedicated small recovery bucket and successful credential login re-evaluates current bans and limiters under authenticated, trusted-user, Admin, or Owner policy. +- The `/user/login?bypass=1` recovery login render path, resolved through the shared `RequestPathResolver`, must remain reachable even when the current Visitor ID or IP bucket is banned; it uses a dedicated small recovery bucket and successful credential login re-evaluates current bans and limiters under authenticated, trusted-user, Admin, or Owner policy. - Successful login and verified provider-backed captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. - Captcha-based reset or `429` recovery requires an active provider-backed challenge. Provider `none`, missing-provider, or disabled-provider auto-success is never human proof and must not reset limits. - The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and Owner-gated manual reset. diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index a2d8c2d6..47f12816 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -65,7 +65,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Evaluated Security signals are limited to source-risk signals. Routine access logs, ordinary successful requests, expected validation failures, and login-required `401` responses do not contribute. Normal `404`, `403`, and `429` responses can still be weak signals because repeated misses, denials, or limiter collisions in a short window are source risk. - Honeypot/probe signals may carry high scores because they represent high-confidence scanner behavior. Ordinary error-page and rate-limit hits must stay low enough that a single legitimate mistake is harmless while repeated hits can still cross the threshold. - Trusted registered users are never auto-banned. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, the required setting also protects Owners from self-lockout through auto-ban. Valid API keys owned by trusted users inherit this bypass because the trusted user context has been resolved before active ban enforcement. -- The recovery login render path `/{LANG}/users/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. +- The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. - Ban decisions follow the Security policy enforcement order so trusted-user context, trusted-user API-key context, active Admin/Owner session context, and recovery-login rendering are resolved before Visitor/IP bans can deny access, while active bans still run before error pages or rate-limit responses can be produced. - Config keys must be registered through the settings/default provider so setup, missing database, or unavailable database states read safe defaults without touching Doctrine/DBAL. When the database is unavailable, signal persistence and score evaluation cannot happen, so auto-ban degrades fail-open. - Active temporary ban responses use the shared `HttpErrorRenderer` forced bare response path: `403 Forbidden`, `Retry-After` when TTL is known, `Cache-Control: no-store`, safe Request ID/reference, and a generic message. They must not expose score values, rule names, subject keys, Visitor IDs, IP buckets, raw IP data, paths, headers, signal internals, or ban history. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index ccc621a9..9729bfec 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -192,7 +192,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - Visitor IDs and IP buckets that resolve to a trusted registered user session or trusted-user-owned API key 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 the recovery login render path `GET /{LANG}/users/login?bypass=1`, resolved through the shared `RequestPathResolver`, so the normal login form remains reachable 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. +- Provide the recovery login render path `GET /user/login?bypass=1`, resolved through the shared `RequestPathResolver`, so the normal login form remains reachable 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. ## Captcha Defaults diff --git a/dev/draft/security-hardening/rate-enforcement.md b/dev/draft/security-hardening/rate-enforcement.md index 6451e07b..ef15c50c 100644 --- a/dev/draft/security-hardening/rate-enforcement.md +++ b/dev/draft/security-hardening/rate-enforcement.md @@ -55,7 +55,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 resolver-matched `/{LANG}/users/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. +- Recovery login bypass is the resolver-matched `/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. @@ -75,7 +75,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - 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 bypass-query login posts; 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: resolver-matched `/{LANG}/users/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 bypass-query login posts; 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: resolver-matched `/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/Controller/SecurityController.php b/src/Controller/SecurityController.php index ace0cfd3..22620b76 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -19,8 +19,6 @@ public function __construct( } #[Route('/user/login', name: 'user_login', methods: ['GET', 'POST'])] - #[Route('/users/login', name: 'user_login_recovery_alias', methods: ['GET'])] - #[Route('/{_locale}/users/login', name: 'user_login_recovery_locale_alias', requirements: ['_locale' => '[A-Za-z]{2}(?:-[A-Za-z0-9]+)?'], methods: ['GET'])] public function login(AuthenticationUtils $authenticationUtils, Request $request): Response { return $this->render('@frontend/user/login.html.twig', [ diff --git a/src/Core/Routing/RequestPathResolver.php b/src/Core/Routing/RequestPathResolver.php index 6f9cdfd4..b9045806 100644 --- a/src/Core/Routing/RequestPathResolver.php +++ b/src/Core/Routing/RequestPathResolver.php @@ -12,7 +12,7 @@ /** * @var list */ - private const LOCALE_PREFIX_SCOPED_SEGMENTS = ['admin', 'editor', 'user', 'users']; + private const LOCALE_PREFIX_SCOPED_SEGMENTS = ['admin', 'editor', 'user']; public function __construct(private ?ContentRouteLocalization $routeLocalization = null) { diff --git a/src/Security/Abuse/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index 6b2b71e6..c2be8c5e 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -141,7 +141,7 @@ private function recoveryLogin(Request $request, string $method, array $segments { return 'GET' === $method && $this->loginSegments($segments) - && $this->routeIs($route, 'user_login', 'user_login_recovery_alias', 'user_login_recovery_locale_alias', 'n/a') + && $this->routeIs($route, 'user_login', 'n/a') && '1' === (string) $request->query->get('bypass', ''); } @@ -150,7 +150,7 @@ private function recoveryLogin(Request $request, string $method, array $segments */ private function loginSegments(array $segments): bool { - return $this->matchesSegments($segments, 'user', 'login') || $this->matchesSegments($segments, 'users', 'login'); + return $this->matchesSegments($segments, 'user', 'login'); } private function setupApply(Request $request, array $segments): bool diff --git a/tests/Core/Routing/RequestPathResolverTest.php b/tests/Core/Routing/RequestPathResolverTest.php index bf48aea9..8eb644ce 100644 --- a/tests/Core/Routing/RequestPathResolverTest.php +++ b/tests/Core/Routing/RequestPathResolverTest.php @@ -21,13 +21,13 @@ public function testItStripsRouteLocaleOnlyBeforeKnownLocalePrefixPathScopes(): $resolver = new RequestPathResolver(); $admin = Request::create('/de/admin/settings/security'); $admin->attributes->set('_locale', 'de'); - $users = Request::create('/de/users/login'); - $users->attributes->set('_locale', 'de'); + $login = Request::create('/de/user/login'); + $login->attributes->set('_locale', 'de'); $content = Request::create('/de/about'); $content->attributes->set('_locale', 'de'); self::assertSame(['admin', 'settings', 'security'], $resolver->segments($admin)); - self::assertSame(['users', 'login'], $resolver->segments($users)); + self::assertSame(['user', 'login'], $resolver->segments($login)); self::assertSame(['de', 'about'], $resolver->segments($content)); } diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php index 2d8d0f45..e9f5ab0b 100644 --- a/tests/Security/Abuse/RequestIntentClassifierTest.php +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -264,8 +264,8 @@ public static function requestCases(): iterable RequestFamily::Browser, RequestIntent::RecoveryLogin, ]; - yield 'plural recovery login bypass uses recovery intent' => [ - self::localizedRequest('/de/users/login?bypass=1', 'GET', 'de'), + yield 'localized recovery login bypass uses recovery intent' => [ + self::localizedRequest('/de/user/login?bypass=1', 'GET', 'de'), RequestFamily::Browser, RequestIntent::RecoveryLogin, ]; diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index 586b14f9..5354edb8 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -69,7 +69,7 @@ public function testRecoveryLoginBypassIsReachableDespiteActiveBan(): void $clock = new MockClock('2026-06-18 12:00:00'); $visitorIds = new VisitorIdGenerator('test-secret'); $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); - $request = Request::create('/de/users/login?bypass=1', server: ['REMOTE_ADDR' => '203.0.113.10']); + $request = Request::create('/de/user/login?bypass=1', server: ['REMOTE_ADDR' => '203.0.113.10']); $request->attributes->set('_locale', 'de'); $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); $store->ban($subject, 3600); diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php index 256de899..408af391 100644 --- a/tests/Security/RateLimit/RateLimitEnforcerTest.php +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -100,8 +100,8 @@ public function testRecoveryLoginBypassUsesDedicatedBucketWithoutWebsiteBudget() self::assertFalse($result->isAllowed()); self::assertSame('security.rate.recovery_login', $result->diagnosticsLabel()); - $localizedRecovery = $this->request('/de/users/login?bypass=1'); - $localizedRecovery->attributes->set('_route', 'user_login_recovery_locale_alias'); + $localizedRecovery = $this->request('/de/user/login?bypass=1'); + $localizedRecovery->attributes->set('_route', 'user_login'); $localizedRecovery->attributes->set('_locale', 'de'); self::assertFalse($enforcer->check($localizedRecovery, RateLimitEnforcementStage::Ordinary)->isAllowed()); From 4e324c193e6cf7114505dbe57a3a059871661d50 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 13:34:44 +0200 Subject: [PATCH 08/55] Document locale-aware delivery boundaries --- dev/draft/0.4.x-FrontendDeliveryCaching.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dev/draft/0.4.x-FrontendDeliveryCaching.md b/dev/draft/0.4.x-FrontendDeliveryCaching.md index 50cb2389..9c724917 100644 --- a/dev/draft/0.4.x-FrontendDeliveryCaching.md +++ b/dev/draft/0.4.x-FrontendDeliveryCaching.md @@ -1,7 +1,7 @@ # Frontend delivery and caching (Feature Draft) > **Status**: Draft -> **Updated**: 2026-06-17 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Draft for public content delivery, snapshot/cache boundaries, HTTP caching, invalidation, and performance-oriented rendering. @@ -23,6 +23,8 @@ The Security rate-limit `panic` profile is also intended as the future switch po ## Technical Specifications - Define public delivery services separate from editor/admin write services. - Use content route resolver output, theme resolver output, schema rendering, media URLs, and resolver context through a controlled read path. +- Keep future locale-prefix path rewriting limited to safe public navigation links. Delivery may rewrite generated read-only `GET` anchors to active locale-prefixed public paths. For generated internal targets that do not support locale prefixes, delivery may instead append or merge an explicit `language` query parameter, including for mutating form actions, API routes, live endpoints, cron/scheduler endpoints, admin/editor mutators, uploads, downloads, and login/logout targets. The query parameter is a language preference, not a routing fallback; existing query parameters must be preserved, and opaque external or signature-protected URLs must only receive it when the generating component owns the full URL contract. +- Treat path classification as separate from routing. `RequestPathResolver` may classify locale-prefixed paths for enforcement and request intent, but it must not be treated as a Symfony route fallback. Localized unsafe routes need explicit route definitions, route generation rules, and focused regression tests. - Provide cache namespaces for public content rendering, navigation/menu data, resolver outputs, schema rendering metadata, media metadata, and API read output where useful. - Use HTTP caching headers such as `ETag`, `Last-Modified`, and `Cache-Control` where safe. - Use AssetMapper hashed asset URLs for long-lived frontend assets. @@ -43,6 +45,7 @@ The Security rate-limit `panic` profile is also intended as the future switch po - Test rebuild/warmup/prune commands. - Test admin maintenance actions for cache and delivery diagnostics once UI exists. - Test cache panic mode activation, TTL expiry, anonymous cache-hit behavior, cache-miss fallback behavior, and authenticated/Admin/Owner bypass boundaries once implemented. +- Test any future locale-aware delivery rewrite with method-aware route coverage: public `GET` anchors may be localized through a locale prefix, generated internal targets without prefix support may receive a `language` query parameter, and unsafe methods, login-check/logout POST flows, API/live endpoints, admin/editor mutators, and unlocalized routes must not receive path rewrites unless explicitly routed. - Run asset build commands after frontend delivery asset changes. ## Implementation Notes @@ -50,6 +53,7 @@ The Security rate-limit `panic` profile is also intended as the future switch po - **Decision recorded:** Symfony-native cache and HTTP headers are the first delivery foundation; external cache/CDN integrations remain optional. - **Decision recorded:** Publish and lifecycle events must define cache invalidation or rebuild behavior. - **Decision recorded:** The Security `panic` profile is the intended coordination point for a future cache panic mode: a bounded TTL lock may force anonymous public traffic to cache-backed delivery during DDoS-like events while strict rate limits remain active. +- **Decision recorded:** Locale-prefix path rewriting must be method-aware and safe-navigation-only, while `language` query propagation is method-agnostic for generated internal targets that do not support locale prefixes. The recovery login render route stays `/user/login?bypass=1`; future frontend delivery must not infer localized POST or recovery aliases from `RequestPathResolver` classification. - **Open:** Re-evaluate small feature-local Symfony cache uses, including the Abuse Foundation suspicious-probe pattern cache and Admin ACL feature/override/group matrix caches, when the unified caching strategy is implemented. Move them to the shared cache namespace/invalidation model if that produces clearer ownership, diagnostics, or operational controls. - **Open:** Decide whether the first release needs persisted snapshot artifacts or only cache-backed read models. - **Open:** Define default cache TTLs and invalidation namespaces after the first public rendering slice exists. From 403e4f61682681320a63d147004a9e745a7d9a3a Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 13:40:18 +0200 Subject: [PATCH 09/55] Harden auto-ban enforcement boundaries --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + dev/draft/security-hardening/auto-ban.md | 2 ++ migrations/Version20260531000000.php | 1 + src/Security/AutoBan/AutoBanRequestSubscriber.php | 2 +- tests/Operations/SqliteMigrationTest.php | 1 + .../AutoBan/AutoBanRequestSubscriberTest.php | 15 +++++++++++++++ 7 files changed, 22 insertions(+), 2 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 5a03a04d..0220c83f 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -202,7 +202,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, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\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 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, records source-subject Visitor/IP signals for high-signal probes and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/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/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, enforces active bans after trusted session/API context but before ordinary rate-limit buckets, returns forced bare `403` with `Retry-After` and request ID, lets trusted users and recovery login bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-gated `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows filtered retained signal detail, and records Owner reset signals that clear active cache state and invalidate earlier scoring evidence. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, returns forced bare `403` with `Retry-After` and request ID, lets trusted users and recovery login bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-gated `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows filtered retained signal detail, and records Owner reset signals that clear active cache state and invalidate earlier scoring evidence. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 76b95b91..9170a1c2 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -96,6 +96,7 @@ - Replaced throw-based auto-ban payload timestamp parsing with bounded `createFromFormat()` parsing and routed auto-ban storage/evaluation degradation through Security Message-layer diagnostics while preserving fail-open behavior. - Added configurable hidden Owner alerts for newly decided bans, linked alert actions directly to the active-ban list, added success/error alerts for manual ban release and failed settings saves, and routed alert-delivery degradation through Security Message-layer diagnostics. - Registered `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing API endpoint registry. Browser and API auto-ban review/reset surfaces use the existing non-configurable `admin.settings.security` ACL gate, so delegated non-Owner admins cannot access the ban list. +- Review-hardened active-ban enforcement so `/api/live/**` remains outside ordinary rate-limit `429` handling but no longer bypasses an already active auto-ban, and added a `request_id`/`reason_code` Security-signal index for the Visitor-over-IP ban dedupe query. - Verification: `php -l` on changed PHP entry points passed; `php bin/console lint:container` passed; focused AutoBan/API/settings/message PHPUnit groups passed; full `php bin/phpunit` passed with 1631 tests and 10708 assertions; `bin/lint --diff` plus explicit lint for new auto-ban files passed. ### Archived Compacted Branch History diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 47f12816..1ad231b3 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -67,6 +67,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Trusted registered users are never auto-banned. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, the required setting also protects Owners from self-lockout through auto-ban. Valid API keys owned by trusted users inherit this bypass because the trusted user context has been resolved before active ban enforcement. - The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. - Ban decisions follow the Security policy enforcement order so trusted-user context, trusted-user API-key context, active Admin/Owner session context, and recovery-login rendering are resolved before Visitor/IP bans can deny access, while active bans still run before error pages or rate-limit responses can be produced. +- `/api/live/**` stays outside ordinary rate-limit rejection, but it is not an active auto-ban bypass. Once a Visitor/IP source is actively banned, live JSON endpoints must receive the same bare `403` enforcement unless the trusted-user or recovery-login policy applies. - Config keys must be registered through the settings/default provider so setup, missing database, or unavailable database states read safe defaults without touching Doctrine/DBAL. When the database is unavailable, signal persistence and score evaluation cannot happen, so auto-ban degrades fail-open. - Active temporary ban responses use the shared `HttpErrorRenderer` forced bare response path: `403 Forbidden`, `Retry-After` when TTL is known, `Cache-Control: no-store`, safe Request ID/reference, and a generic message. They must not expose score values, rule names, subject keys, Visitor IDs, IP buckets, raw IP data, paths, headers, signal internals, or ban history. - Cache-flock state is the active enforcement state holder. Explainability and escalation come from retained `security_signal_event` records, including ban-trigger and reset records, not from a separate durable ban table. @@ -109,6 +110,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test valid trusted-user-owned API keys bypass active Visitor/IP bans after API-key authentication resolves the trusted user context, while non-trusted or invalid API-key requests remain subject to Visitor/IP source enforcement. - Test subscriber ordering against existing probe, API authentication, browser session, ordinary rate-limit, and error-rendering hooks. - Test recovery-login bypass render despite active Visitor/IP bans, dedicated recovery-login bucket behavior, CSRF/credential/failure accounting, audit logging, and post-login re-evaluation. +- Test `/api/live/**` remains outside ordinary rate-limit `429` handling but does not bypass an already active auto-ban. - Test bare browser `403` response shape, `Retry-After`, request ID, `no-store`, and redaction. - Test Admin active-ban list, detail filtering, API list/detail/reset endpoints, and manual reset permissions/audit/signal creation. - Test that delegated non-Owner admins cannot access active-ban browser or API surfaces through the `admin.settings.security` ACL gate. diff --git a/migrations/Version20260531000000.php b/migrations/Version20260531000000.php index 14b46855..f3f8ff4f 100644 --- a/migrations/Version20260531000000.php +++ b/migrations/Version20260531000000.php @@ -251,6 +251,7 @@ public function up(Schema $schema): void $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'); + $this->addIndex($securitySignalLog, ['request_id', 'reason_code'], 'idx_security_signal_request_reason'); $aclGroup = $schema->createTable('acl_group'); $aclGroup->addColumn('uid', 'string', ['length' => 36]); diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index 45f3d41d..6e604cc5 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -132,7 +132,7 @@ private function banResponse(Request $request, ActiveAutoBan $ban): Response private function excludedRequest(Request $request): bool { - return $this->paths->matchesAnyPrefix($request->getPathInfo(), '/api/live', '/assets', '/build', '/_profiler', '/_wdt') + return $this->paths->matchesAnyPrefix($request->getPathInfo(), '/assets', '/build', '/_profiler', '/_wdt') || in_array($request->getPathInfo(), ['/favicon.ico', '/robots.txt'], true); } diff --git a/tests/Operations/SqliteMigrationTest.php b/tests/Operations/SqliteMigrationTest.php index a6c9a4f6..81a4215f 100644 --- a/tests/Operations/SqliteMigrationTest.php +++ b/tests/Operations/SqliteMigrationTest.php @@ -120,6 +120,7 @@ public function testPrefixedMigrationsUsePrefixedSchemaObjectNames(): void 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_idx_security_signal_request_reason', $signalIndexes); self::assertContains('studio_fk_user_acl_group_user', $userGroupForeignKeys); self::assertContains('studio_fk_user_acl_group_group', $userGroupForeignKeys); } finally { diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index 5354edb8..04d9484e 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -80,6 +80,21 @@ public function testRecoveryLoginBypassIsReachableDespiteActiveBan(): void self::assertNull($event->getResponse()); } + public function testLiveEndpointsDoNotBypassActiveBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/api/live/status', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + } + public function testTrustedUsersBypassActiveVisitorBans(): void { $clock = new MockClock('2026-06-18 12:00:00'); From 4a4344a1e3160ef3db51579354dfa048efe49391 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 14:32:13 +0200 Subject: [PATCH 10/55] Allow auto-ban recovery login submissions --- .../AutoBan/AutoBanRequestSubscriber.php | 14 +++++++++++++- .../AutoBan/AutoBanRequestSubscriberTest.php | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index 6e604cc5..fa0105bc 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -12,6 +12,7 @@ use App\Security\SecurityMessageCode; use App\Security\SecurityMessageKey; use App\Security\Abuse\AbuseRequestInspector; +use App\Security\Abuse\AbuseRequestProfile; use App\Security\Abuse\AbuseSubject; use App\Security\Abuse\AbuseSubjectType; use App\Security\Abuse\RequestIntent; @@ -65,7 +66,7 @@ public function onKernelRequest(RequestEvent $event): void try { $inspection = $this->inspector->inspect($request); - if (RequestIntent::RecoveryLogin === $inspection['profile']->intent() || $this->trustedContext($inspection['subjects']->subjects())) { + if ($this->recoveryRequest($inspection['profile']) || $this->trustedContext($inspection['subjects']->subjects())) { return; } @@ -118,6 +119,17 @@ private function trustedContext(array $subjects): bool return false; } + private function recoveryRequest(AbuseRequestProfile $profile): bool + { + if (RequestIntent::RecoveryLogin === $profile->intent()) { + return true; + } + + return RequestIntent::Login === $profile->intent() + && 'POST' === $profile->method() + && in_array($profile->route(), ['user_login', 'n/a'], true); + } + private function banResponse(Request $request, ActiveAutoBan $ban): Response { $retryAfter = $ban->retryAfterSeconds($this->clock->now()); diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index 04d9484e..54afc020 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -80,6 +80,22 @@ public function testRecoveryLoginBypassIsReachableDespiteActiveBan(): void self::assertNull($event->getResponse()); } + public function testLoginSubmissionsCanEstablishTrustedRecoveryContextDespiteActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/user/login', 'POST', ['username' => 'owner'], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + public function testLiveEndpointsDoNotBypassActiveBans(): void { $clock = new MockClock('2026-06-18 12:00:00'); From c160314cc54af14ba9e404836b7f9b00110c0df3 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 14:33:19 +0200 Subject: [PATCH 11/55] Serialize auto-ban index updates --- src/Security/AutoBan/AutoBanStore.php | 69 ++++++++++++++++----- tests/Security/AutoBan/AutoBanStoreTest.php | 40 ++++++++++++ 2 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 tests/Security/AutoBan/AutoBanStoreTest.php diff --git a/src/Security/AutoBan/AutoBanStore.php b/src/Security/AutoBan/AutoBanStore.php index 7598a75f..78393c12 100644 --- a/src/Security/AutoBan/AutoBanStore.php +++ b/src/Security/AutoBan/AutoBanStore.php @@ -18,6 +18,7 @@ { private const KEY_PREFIX = 'security.auto_ban.active.'; private const INDEX_KEY = 'security.auto_ban.index.v1'; + private const INDEX_LOCK_KEY = 'security.auto_ban.index.lock'; public function __construct( private CacheItemPoolInterface $cache, @@ -61,7 +62,11 @@ public function ban(AutoBanSubject $subject, int $ttlSeconds, array $context = [ return null; } - $this->upsertIndex($ban); + if (!$this->upsertIndex($ban)) { + $this->cache->deleteItem($this->cacheKey($ban->key())); + + return null; + } return $ban; } catch (Throwable $error) { @@ -161,22 +166,55 @@ private function cacheKey(string $key): string return self::KEY_PREFIX.$key; } - private function upsertIndex(ActiveAutoBan $ban): void + private function upsertIndex(ActiveAutoBan $ban): bool { - $index = $this->index(); - $index[$ban->key()] = [ - 'subject_type' => $ban->subjectType(), - 'subject_label' => $ban->subjectLabel(), - 'expires_at' => $ban->expiresAt()->format('Y-m-d H:i:s'), - ]; - $this->saveIndex($index); + return $this->updateIndex(static function (array $index) use ($ban): array { + $index[$ban->key()] = [ + 'subject_type' => $ban->subjectType(), + 'subject_label' => $ban->subjectLabel(), + 'expires_at' => $ban->expiresAt()->format('Y-m-d H:i:s'), + ]; + + return $index; + }, 'index_upsert', ['active_ban_key' => $ban->key()]); } - private function removeIndex(string $key): void + private function removeIndex(string $key): bool { - $index = $this->index(); - unset($index[$key]); - $this->saveIndex($index); + return $this->updateIndex(static function (array $index) use ($key): array { + unset($index[$key]); + + return $index; + }, 'index_remove', ['active_ban_key' => $key]); + } + + /** + * @param callable(array): array $mutator + * @param array $context + */ + private function updateIndex(callable $mutator, string $operation, array $context = []): bool + { + $lock = $this->lockFactory->createLock(self::INDEX_LOCK_KEY, 5.0); + + try { + if (!$lock->acquire(true)) { + $this->reportStorage($operation, new \RuntimeException('Auto-ban index lock unavailable.'), $context); + + return false; + } + + return $this->saveIndex($mutator($this->index())); + } catch (Throwable $error) { + $this->reportStorage($operation, $error, $context); + + return false; + } finally { + try { + $lock->release(); + } catch (Throwable $error) { + $this->reportStorage('index_lock_release', $error, $context); + } + } } /** @@ -192,12 +230,13 @@ private function index(): array /** * @param array $index */ - private function saveIndex(array $index): void + private function saveIndex(array $index): bool { $item = $this->cache->getItem(self::INDEX_KEY); $item->set($index); $item->expiresAfter(604800); - $this->cache->save($item); + + return $this->cache->save($item); } /** diff --git a/tests/Security/AutoBan/AutoBanStoreTest.php b/tests/Security/AutoBan/AutoBanStoreTest.php new file mode 100644 index 00000000..a38c7b17 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanStoreTest.php @@ -0,0 +1,40 @@ +ban($subject, 3600)); + self::assertNull($store->active($subject)); + self::assertSame([], $store->activeBans()); + } +} + +final class IndexFailingAutoBanCache extends ArrayAdapter +{ + public function save(CacheItemInterface $item): bool + { + if ('security.auto_ban.index.v1' === $item->getKey()) { + return false; + } + + return parent::save($item); + } +} From 306b7792485a211b232664060b131eb1b2d4fdd1 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 14:34:19 +0200 Subject: [PATCH 12/55] Fail auto-ban reset on cache delete failure --- src/Security/AutoBan/AutoBanStore.php | 19 +++++++++++++++- tests/Security/AutoBan/AutoBanStoreTest.php | 24 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Security/AutoBan/AutoBanStore.php b/src/Security/AutoBan/AutoBanStore.php index 78393c12..58b4eec6 100644 --- a/src/Security/AutoBan/AutoBanStore.php +++ b/src/Security/AutoBan/AutoBanStore.php @@ -118,7 +118,24 @@ public function reset(string $key): ?ActiveAutoBan { try { $ban = $this->activeByKey($key); - $this->cache->deleteItem($this->cacheKey($key)); + if (!$ban instanceof ActiveAutoBan) { + $this->removeIndex($key); + + return null; + } + + if (!$this->cache->deleteItem($this->cacheKey($key))) { + $this->reportStorage('reset_delete', new \RuntimeException('Active auto-ban cache delete failed.'), ['active_ban_key' => $key]); + + return null; + } + + if (null !== $this->activeByKey($key)) { + $this->reportStorage('reset_verify', new \RuntimeException('Active auto-ban cache entry remained after delete.'), ['active_ban_key' => $key]); + + return null; + } + $this->removeIndex($key); return $ban; diff --git a/tests/Security/AutoBan/AutoBanStoreTest.php b/tests/Security/AutoBan/AutoBanStoreTest.php index a38c7b17..ea241a42 100644 --- a/tests/Security/AutoBan/AutoBanStoreTest.php +++ b/tests/Security/AutoBan/AutoBanStoreTest.php @@ -25,6 +25,18 @@ public function testBanRollsBackActiveStateWhenIndexCannotBeUpdated(): void self::assertNull($store->active($subject)); self::assertSame([], $store->activeBans()); } + + public function testResetFailsWhenActiveCacheDeleteFails(): void + { + $cache = new DeleteFailingAutoBanCache(); + $store = new AutoBanStore($cache, new LockFactory(new InMemoryStore()), clock: new MockClock('2026-06-18 12:00:00')); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-delete-failure'); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + + self::assertNull($store->reset($ban->key())); + self::assertNotNull($store->active($subject)); + } } final class IndexFailingAutoBanCache extends ArrayAdapter @@ -38,3 +50,15 @@ public function save(CacheItemInterface $item): bool return parent::save($item); } } + +final class DeleteFailingAutoBanCache extends ArrayAdapter +{ + public function deleteItem(mixed $key): bool + { + if (is_string($key) && str_starts_with($key, 'security.auto_ban.active.')) { + return false; + } + + return parent::deleteItem($key); + } +} From 679da8beee1b3d79504b62698c3c44638b227486 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 14:36:13 +0200 Subject: [PATCH 13/55] Require auto-ban reset signal persistence --- src/Controller/AdminAutoBanController.php | 49 ++++++++------- src/Security/Abuse/SecuritySignalRecorder.php | 8 ++- .../AutoBan/Api/AutoBanApiHandler.php | 61 ++++++++++++------- .../Abuse/SecuritySignalRecorderTest.php | 6 +- 4 files changed, 75 insertions(+), 49 deletions(-) diff --git a/src/Controller/AdminAutoBanController.php b/src/Controller/AdminAutoBanController.php index 7f2ca988..20b15990 100644 --- a/src/Controller/AdminAutoBanController.php +++ b/src/Controller/AdminAutoBanController.php @@ -13,6 +13,7 @@ use App\Entity\UserAccount; use App\Form\FormTokenValidator; use App\Security\Abuse\SecuritySignalRecorder; +use App\Security\AutoBan\ActiveAutoBan; use App\Security\AutoBan\AutoBanAdminBrowser; use App\Security\AutoBan\AutoBanScoreCatalogue; use App\Security\AutoBan\AutoBanStore; @@ -86,28 +87,8 @@ public function reset(Request $request, string $key): Response return $this->httpError->resolve(Response::HTTP_FORBIDDEN, $request, context: ['auto_ban_key' => $key]); } - $ban = $this->store->reset($key); - if (null !== $ban) { - $this->signals->record( - 'auto_ban', - AutoBanScoreCatalogue::SIGNAL_RESET, - $ban->subjectType(), - $ban->subjectIdentifier(), - ipDerived: 'ip_bucket' === $ban->subjectType(), - severity: 'NOTICE', - confidence: 100, - requestFamily: 'admin', - requestIntent: 'settings_mutation', - requestId: $this->requestMetadata->requestId($request), - visitorId: 'n/a', - path: $this->requestMetadata->sanitizedPath($request), - route: 'backend_admin_auto_ban_reset', - context: [ - 'active_ban_key' => $key, - 'effective_subject_type' => 'ip_bucket' === $ban->subjectType() ? 'ip' : 'visitor', - 'reset_by' => $this->actor()->userUid(), - ], - ); + $ban = $this->store->activeByKey($key); + if ($ban instanceof ActiveAutoBan && $this->recordResetSignal($request, $key, $ban) && null !== $this->store->reset($key)) { $this->auditReset($key, $ban->subjectType()); $this->alerts->addAlert(UiAlertTranslation::success('admin.auto_bans.reset.saved'), UiAlertDelivery::Direct); } else { @@ -153,6 +134,30 @@ private function resetFormId(string $key): string return 'admin-auto-ban-reset-'.$key; } + private function recordResetSignal(Request $request, string $key, ActiveAutoBan $ban): bool + { + return $this->signals->record( + 'auto_ban', + AutoBanScoreCatalogue::SIGNAL_RESET, + $ban->subjectType(), + $ban->subjectIdentifier(), + ipDerived: 'ip_bucket' === $ban->subjectType(), + severity: 'NOTICE', + confidence: 100, + requestFamily: 'admin', + requestIntent: 'settings_mutation', + requestId: $this->requestMetadata->requestId($request), + visitorId: 'n/a', + path: $this->requestMetadata->sanitizedPath($request), + route: 'backend_admin_auto_ban_reset', + context: [ + 'active_ban_key' => $key, + 'effective_subject_type' => 'ip_bucket' === $ban->subjectType() ? 'ip' : 'visitor', + 'reset_by' => $this->actor()->userUid(), + ], + ); + } + private function auditReset(string $key, string $subjectType): void { try { diff --git a/src/Security/Abuse/SecuritySignalRecorder.php b/src/Security/Abuse/SecuritySignalRecorder.php index 4e1d18ee..7893db24 100644 --- a/src/Security/Abuse/SecuritySignalRecorder.php +++ b/src/Security/Abuse/SecuritySignalRecorder.php @@ -48,9 +48,9 @@ public function record( string $route = self::PLACEHOLDER, ?int $httpStatus = null, array $context = [], - ): void { + ): bool { if (null !== $this->databaseReadyState && !$this->databaseReadyState->isReady()) { - return; + return false; } $now = $this->clock->now(); @@ -91,8 +91,10 @@ public function record( $this->connection->insert(self::TABLE, $row); $this->purgeExpired(); $this->autoBanSignals?->afterSignalRecorded([...$row, ...$context]); + + return true; } catch (Throwable) { - return; + return false; } } diff --git a/src/Security/AutoBan/Api/AutoBanApiHandler.php b/src/Security/AutoBan/Api/AutoBanApiHandler.php index d5871146..369f1f50 100644 --- a/src/Security/AutoBan/Api/AutoBanApiHandler.php +++ b/src/Security/AutoBan/Api/AutoBanApiHandler.php @@ -17,6 +17,7 @@ use App\Core\Message\Message; use App\Core\Message\MessageLevel; use App\Security\Abuse\SecuritySignalRecorder; +use App\Security\AutoBan\ActiveAutoBan; use App\Security\AutoBan\AutoBanAdminBrowser; use App\Security\AutoBan\AutoBanScoreCatalogue; use App\Security\AutoBan\AutoBanStore; @@ -74,7 +75,7 @@ private function reset(Request $request, string $key, ApiEndpointDefinition $end return $denied; } - $ban = $this->store->reset($key); + $ban = $this->store->activeByKey($key); if (null === $ban) { return $this->operationUnavailable($request, $endpoint->operationId(), [ 'active_ban_key' => $key, @@ -82,27 +83,20 @@ private function reset(Request $request, string $key, ApiEndpointDefinition $end ]); } - $this->signals->record( - 'auto_ban', - AutoBanScoreCatalogue::SIGNAL_RESET, - $ban->subjectType(), - $ban->subjectIdentifier(), - ipDerived: 'ip_bucket' === $ban->subjectType(), - severity: 'NOTICE', - confidence: 100, - requestFamily: 'api', - requestIntent: 'settings_mutation', - requestId: $this->requestMetadata->requestId($request), - visitorId: 'n/a', - path: $this->requestMetadata->sanitizedPath($request), - route: 'api_v1_endpoint_dispatch', - context: [ + if (!$this->recordResetSignal($request, $endpoint, $key, $ban)) { + return $this->operationUnavailable($request, $endpoint->operationId(), [ 'active_ban_key' => $key, - 'effective_subject_type' => 'ip_bucket' === $ban->subjectType() ? 'ip' : 'visitor', - 'reset_by' => $this->actor($request)->userUid(), - 'api_operation' => $endpoint->operationId(), - ], - ); + 'reason' => 'reset_signal_not_recorded', + ]); + } + + if (null === $this->store->reset($key)) { + return $this->operationUnavailable($request, $endpoint->operationId(), [ + 'active_ban_key' => $key, + 'reason' => 'active_ban_reset_failed', + ]); + } + $this->auditReset($request, $key, $ban->subjectType()); return $this->responder->data( @@ -199,6 +193,31 @@ private function actor(Request $request): AccessActor return ApiRequestContext::fromRequest($request)?->actor() ?? AccessActor::anonymous(); } + private function recordResetSignal(Request $request, ApiEndpointDefinition $endpoint, string $key, ActiveAutoBan $ban): bool + { + return $this->signals->record( + 'auto_ban', + AutoBanScoreCatalogue::SIGNAL_RESET, + $ban->subjectType(), + $ban->subjectIdentifier(), + ipDerived: 'ip_bucket' === $ban->subjectType(), + severity: 'NOTICE', + confidence: 100, + requestFamily: 'api', + requestIntent: 'settings_mutation', + requestId: $this->requestMetadata->requestId($request), + visitorId: 'n/a', + path: $this->requestMetadata->sanitizedPath($request), + route: 'api_v1_endpoint_dispatch', + context: [ + 'active_ban_key' => $key, + 'effective_subject_type' => 'ip_bucket' === $ban->subjectType() ? 'ip' : 'visitor', + 'reset_by' => $this->actor($request)->userUid(), + 'api_operation' => $endpoint->operationId(), + ], + ); + } + private function auditReset(Request $request, string $key, string $subjectType): void { try { diff --git a/tests/Security/Abuse/SecuritySignalRecorderTest.php b/tests/Security/Abuse/SecuritySignalRecorderTest.php index c252a67e..03fba595 100644 --- a/tests/Security/Abuse/SecuritySignalRecorderTest.php +++ b/tests/Security/Abuse/SecuritySignalRecorderTest.php @@ -31,7 +31,7 @@ public function testItDoesNotTouchDatabaseWhenSetupIsNotComplete(): void new DatabaseReadyState(new SetupCompletionMarker(), sys_get_temp_dir().'/missing-system-project', 'test'), ); - $recorder->record('probe', 'security.probe.setup', 'visitor', 'visitor-id'); + self::assertFalse($recorder->record('probe', 'security.probe.setup', 'visitor', 'visitor-id')); } finally { $this->restoreEnvironment(SetupCompletionMarker::KEY, $setupState); $this->restoreEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, $allowState); @@ -77,7 +77,7 @@ public function testItRecordsSignalsWithSharedRetentionAndPurgesExpiredRows(): v new DatabaseLogRetentionPolicy($connection), clock: new MockClock('2026-06-16 12:00:00'), ); - $recorder->record( + self::assertTrue($recorder->record( 'probe', 'security.probe.env', 'ip_hash', @@ -89,7 +89,7 @@ public function testItRecordsSignalsWithSharedRetentionAndPurgesExpiredRows(): v 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')); From 663b5a5fc814b02e03b240ffc9e03dd7d250a377 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 14:38:38 +0200 Subject: [PATCH 14/55] Record trigger effects only for new auto-bans --- .../AutoBan/AutoBanSignalEvaluator.php | 5 ++-- src/Security/AutoBan/AutoBanStore.php | 16 ++++++++++--- src/Security/AutoBan/AutoBanStoreResult.php | 24 +++++++++++++++++++ tests/Security/AutoBan/AutoBanStoreTest.php | 15 ++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 src/Security/AutoBan/AutoBanStoreResult.php diff --git a/src/Security/AutoBan/AutoBanSignalEvaluator.php b/src/Security/AutoBan/AutoBanSignalEvaluator.php index f382b9cd..46a3208c 100644 --- a/src/Security/AutoBan/AutoBanSignalEvaluator.php +++ b/src/Security/AutoBan/AutoBanSignalEvaluator.php @@ -83,7 +83,7 @@ public function afterSignalRecorded(array $signal): void $priorBanSignals = $this->priorBanSignalCount($subject); $ttlSeconds = $this->policy->ttlForEscalationCount($priorBanSignals); - $ban = $this->store->ban($subject, $ttlSeconds, [ + $banResult = $this->store->createOrReturnActive($subject, $ttlSeconds, [ 'score' => $summary['score'], 'signal_count' => $summary['signal_count'], 'threshold' => $this->policy->thresholdFor($subjectType), @@ -91,10 +91,11 @@ public function afterSignalRecorded(array $signal): void 'escalation_index' => $priorBanSignals, ]); - if (null === $ban) { + if (null === $banResult || !$banResult->created()) { return; } + $ban = $banResult->ban(); if (!$this->recordBanTriggeredSignal($subject, $ban, $summary, $signal, $priorBanSignals)) { $this->store->reset($ban->key()); diff --git a/src/Security/AutoBan/AutoBanStore.php b/src/Security/AutoBan/AutoBanStore.php index 58b4eec6..a9fb7ece 100644 --- a/src/Security/AutoBan/AutoBanStore.php +++ b/src/Security/AutoBan/AutoBanStore.php @@ -32,16 +32,26 @@ public function __construct( * @param array $context */ public function ban(AutoBanSubject $subject, int $ttlSeconds, array $context = []): ?ActiveAutoBan + { + return $this->createOrReturnActive($subject, $ttlSeconds, $context)?->ban(); + } + + /** + * @param array $context + */ + public function createOrReturnActive(AutoBanSubject $subject, int $ttlSeconds, array $context = []): ?AutoBanStoreResult { $lock = $this->lockFactory->createLock(self::KEY_PREFIX.$subject->key(), 5.0); try { if (!$lock->acquire()) { - return $this->active($subject); + $active = $this->active($subject); + + return $active instanceof ActiveAutoBan ? new AutoBanStoreResult($active, false) : null; } if (null !== ($active = $this->active($subject))) { - return $active; + return new AutoBanStoreResult($active, false); } $now = $this->clock->now(); @@ -68,7 +78,7 @@ public function ban(AutoBanSubject $subject, int $ttlSeconds, array $context = [ return null; } - return $ban; + return new AutoBanStoreResult($ban, true); } catch (Throwable $error) { $this->reportStorage('ban', $error, ['active_ban_key' => $subject->key()]); diff --git a/src/Security/AutoBan/AutoBanStoreResult.php b/src/Security/AutoBan/AutoBanStoreResult.php new file mode 100644 index 00000000..f5664f0c --- /dev/null +++ b/src/Security/AutoBan/AutoBanStoreResult.php @@ -0,0 +1,24 @@ +ban; + } + + public function created(): bool + { + return $this->created; + } +} diff --git a/tests/Security/AutoBan/AutoBanStoreTest.php b/tests/Security/AutoBan/AutoBanStoreTest.php index ea241a42..dfb0c0af 100644 --- a/tests/Security/AutoBan/AutoBanStoreTest.php +++ b/tests/Security/AutoBan/AutoBanStoreTest.php @@ -37,6 +37,21 @@ public function testResetFailsWhenActiveCacheDeleteFails(): void self::assertNull($store->reset($ban->key())); self::assertNotNull($store->active($subject)); } + + public function testCreateOrReturnActiveMarksExistingBanAsNotCreated(): void + { + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: new MockClock('2026-06-18 12:00:00')); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-existing'); + + $first = $store->createOrReturnActive($subject, 3600); + self::assertNotNull($first); + self::assertTrue($first->created()); + + $second = $store->createOrReturnActive($subject, 3600); + self::assertNotNull($second); + self::assertFalse($second->created()); + self::assertSame($first->ban()->key(), $second->ban()->key()); + } } final class IndexFailingAutoBanCache extends ArrayAdapter From be97a5b732062bd01c2f087f4643f78cfe5b20b0 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 14:39:54 +0200 Subject: [PATCH 15/55] Keep newest auto-ban detail signals visible --- src/Security/AutoBan/AutoBanAdminBrowser.php | 2 +- .../PassiveAbuseSignalSubscriberTest.php | 29 +++++++ .../AutoBan/AutoBanAdminBrowserTest.php | 82 +++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/Security/AutoBan/AutoBanAdminBrowserTest.php diff --git a/src/Security/AutoBan/AutoBanAdminBrowser.php b/src/Security/AutoBan/AutoBanAdminBrowser.php index c38ec733..18d6601a 100644 --- a/src/Security/AutoBan/AutoBanAdminBrowser.php +++ b/src/Security/AutoBan/AutoBanAdminBrowser.php @@ -59,7 +59,7 @@ private function signals(ActiveAutoBan $ban): array { try { $rows = $this->connection->fetchAllAssociative( - 'SELECT uid, occurred_at, signal_type, reason_code, severity, confidence, request_id, visitor_id, path, route, http_status, context FROM '.self::TABLE.' WHERE subject_type = ? AND subject_identifier = ? AND expires_at > ? ORDER BY occurred_at DESC LIMIT 100', + 'SELECT uid, occurred_at, signal_type, reason_code, severity, confidence, request_id, visitor_id, path, route, http_status, context FROM '.self::TABLE.' WHERE subject_type = ? AND subject_identifier = ? AND expires_at > ? ORDER BY occurred_at DESC, uid DESC LIMIT 100', [ $ban->subjectType(), $ban->subjectIdentifier(), diff --git a/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php index f24f082d..01145f02 100644 --- a/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php +++ b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php @@ -13,6 +13,7 @@ use App\Security\Abuse\PassiveAbuseSignalSubscriber; use App\Security\Abuse\RequestIntentClassifier; use App\Security\Abuse\SecuritySignalRecorder; +use App\Security\AutoBan\AutoBanRequestSubscriber; use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; @@ -129,6 +130,34 @@ public function testItRecordsErrorStatusSignalsButExcludesLoginRequired401(): vo self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event WHERE http_status = 401')); } + public function testItDoesNotRecordAutoBanEnforcementResponses(): 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(), + ); + $request = Request::create('/missing', server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + new Response('', 403), + )); + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + public function testItSanitizesTokenizedPathsBeforeRecordingSignals(): void { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); diff --git a/tests/Security/AutoBan/AutoBanAdminBrowserTest.php b/tests/Security/AutoBan/AutoBanAdminBrowserTest.php new file mode 100644 index 00000000..efa8c941 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanAdminBrowserTest.php @@ -0,0 +1,82 @@ +connection(); + $clock = new MockClock('2026-06-18 13:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-detail-history'); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + + for ($i = 0; $i < 105; ++$i) { + $this->insertSignal($connection, $subject, sprintf('old-%03d', $i), sprintf('2026-06-18 11:%02d:%02d', intdiv($i, 60), $i % 60)); + } + $this->insertSignal($connection, $subject, 'reset', '2026-06-18 12:00:00', AutoBanScoreCatalogue::SIGNAL_RESET); + $this->insertSignal($connection, $subject, 'new-001', '2026-06-18 12:01:00'); + $this->insertSignal($connection, $subject, 'new-002', '2026-06-18 12:02:00'); + + $detail = (new AutoBanAdminBrowser($store, $connection, clock: $clock))->detail($ban->key()); + self::assertNotNull($detail); + + self::assertCount(100, $detail['signals']); + self::assertSame('new-002', $detail['signals'][0]['uid']); + self::assertSame('new-001', $detail['signals'][1]['uid']); + self::assertContains('old-104', array_column($detail['signals'], 'uid')); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + + return $connection; + } + + private function insertSignal( + Connection $connection, + AutoBanSubject $subject, + string $uid, + string $occurredAt, + string $reasonCode = AutoBanScoreCatalogue::SIGNAL_ERROR_HIT, + ): void { + $connection->insert('security_signal_event', [ + 'uid' => $uid, + 'occurred_at' => $occurredAt, + 'expires_at' => '2026-06-25 00:00:00', + 'signal_type' => 'http_error', + 'reason_code' => $reasonCode, + 'severity' => 'NOTICE', + 'confidence' => 40, + 'subject_type' => $subject->type(), + 'subject_identifier' => $subject->identifier(), + 'ip_derived' => 0, + 'request_family' => 'frontend', + 'request_intent' => 'navigation', + 'request_id' => 'request-'.$uid, + 'visitor_id' => $subject->identifier(), + 'path' => '/missing', + 'route' => 'n/a', + 'http_status' => 404, + 'context' => '{}', + ]); + } +} From 81610c92c03f0e6c76699fcd9477f2ecc8530c9f Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 14:40:31 +0200 Subject: [PATCH 16/55] Mark auto-ban API endpoints owner-only --- src/Security/AutoBan/Api/AutoBanApiEndpointProvider.php | 2 ++ tests/Controller/ApiAdminOperationalControllerTest.php | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Security/AutoBan/Api/AutoBanApiEndpointProvider.php b/src/Security/AutoBan/Api/AutoBanApiEndpointProvider.php index 231103fb..d89815df 100644 --- a/src/Security/AutoBan/Api/AutoBanApiEndpointProvider.php +++ b/src/Security/AutoBan/Api/AutoBanApiEndpointProvider.php @@ -6,6 +6,7 @@ use App\Api\Endpoint\ApiEndpointDefinition; use App\Api\Endpoint\ApiEndpointProviderInterface; +use App\Core\Access\AccessLevel; use Symfony\Component\HttpFoundation\Request; final readonly class AutoBanApiEndpointProvider implements ApiEndpointProviderInterface @@ -65,6 +66,7 @@ private function endpoint( ['backend-admin', 'backend-admin-security'], parameters: $parameters, responseSchema: $responseSchema ?? ['type' => 'object'], + minimumAccessLevel: AccessLevel::OWNER, pathPattern: $pathPattern, ); } diff --git a/tests/Controller/ApiAdminOperationalControllerTest.php b/tests/Controller/ApiAdminOperationalControllerTest.php index eb62b440..27a202ef 100644 --- a/tests/Controller/ApiAdminOperationalControllerTest.php +++ b/tests/Controller/ApiAdminOperationalControllerTest.php @@ -67,8 +67,9 @@ public function testAdminSecurityAutoBansAreOwnerGated(): void self::assertResponseStatusCodeSame(403); $payload = $this->jsonPayload($client->getResponse()->getContent()); - self::assertSame('admin.settings.security', $payload['error']['context']['feature']); - self::assertSame('feature_hidden', $payload['error']['context']['reason']); + self::assertSame(AccessLevel::OWNER, $payload['error']['context']['required_access_level']); + self::assertSame(AccessLevel::ADMIN, $payload['error']['context']['actor_access_level']); + self::assertSame('listAdminSecurityAutoBans', $payload['error']['context']['operation_id']); } public function testAdminSecurityAutoBansListDetailAndResetAreExposedByApi(): void @@ -518,7 +519,7 @@ public function testOpenApiIncludesAdminOperationalEndpoints(): void self::assertSame(['backend-admin', 'backend-admin-scheduler'], $payload['paths']['/admin/scheduler']['get']['tags']); self::assertSame(['backend-admin', 'backend-admin-security'], $payload['paths']['/admin/security/auto-bans']['get']['tags']); self::assertSame('resetAdminSecurityAutoBan', $payload['paths']['/admin/security/auto-bans/{key}/reset']['post']['operationId']); - self::assertSame(AccessLevel::ADMIN, $payload['paths']['/admin/security/auto-bans']['get']['x-access']['required_access_level']); + self::assertSame(AccessLevel::OWNER, $payload['paths']['/admin/security/auto-bans']['get']['x-access']['required_access_level']); 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( From 8f14543eec7b71914cf143524dcbb965be311f16 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 14:42:14 +0200 Subject: [PATCH 17/55] Document auto-ban review hardening --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + dev/draft/security-hardening/auto-ban.md | 16 ++++++++-------- dev/manual/security-guard-snippets.md | 8 ++++---- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 0220c83f..0d30d397 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -202,7 +202,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, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\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 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, records source-subject Visitor/IP signals for high-signal probes and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/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/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, returns forced bare `403` with `Retry-After` and request ID, lets trusted users and recovery login bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-gated `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows filtered retained signal detail, and records Owner reset signals that clear active cache state and invalidate earlier scoring evidence. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets trusted users and recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, and records Owner reset signals that clear active cache state and invalidate earlier scoring evidence. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 9170a1c2..300da741 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -98,6 +98,7 @@ - Registered `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing API endpoint registry. Browser and API auto-ban review/reset surfaces use the existing non-configurable `admin.settings.security` ACL gate, so delegated non-Owner admins cannot access the ban list. - Review-hardened active-ban enforcement so `/api/live/**` remains outside ordinary rate-limit `429` handling but no longer bypasses an already active auto-ban, and added a `request_id`/`reason_code` Security-signal index for the Visitor-over-IP ban dedupe query. - Verification: `php -l` on changed PHP entry points passed; `php bin/console lint:container` passed; focused AutoBan/API/settings/message PHPUnit groups passed; full `php bin/phpunit` passed with 1631 tests and 10708 assertions; `bin/lint --diff` plus explicit lint for new auto-ban files passed. +- Addressed first Cloud Review round with separate reviewable commits: recovery login submissions can authenticate despite source bans; active-ban index updates are serialized and roll back unindexed active state; failed cache deletes make reset fail; reset success requires reset-signal persistence; concurrent evaluators emit trigger signals/Owner alerts only for newly created bans; detail views are newest-first while retaining history; auto-ban `403` responses do not create passive signals; and auto-ban API endpoints now advertise Owner-level access before the handler ACL gate. ### 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 1ad231b3..a86bcd6d 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -34,14 +34,14 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 4. Add an auto-ban policy service that aggregates retained, non-reset Security signals over a one-hour scoring window by Visitor ID first and by stable client IP bucket/HMAC as a secondary source subject. Score aggregation runs only from the qualifying signal write path, reusing the active database connection after signal persistence; ordinary requests that do not create a scoreable signal must not perform database score lookups. 5. Ensure scoreable request signals are persisted for every evaluated source subject, normally Visitor ID and IP bucket, with shared request/correlation context so Visitor and IP scoring can use indexed `subject_type`/`subject_identifier` reads instead of portable-unsafe JSON filtering. 6. Add bounded Owner-gated Config/Settings defaults through the existing settings registry/default provider for auto-ban enablement, trusted-user minimum access level, score threshold, Owner alert delivery for newly decided bans, and any required bounded policy constants so missing databases use seeded defaults and do not cause Doctrine/DBAL throws during setup or degraded states. -7. Add cache-flock-backed active ban state with TTL plus a cache-backed active-ban index for Admin list rendering. The ban store and index must be fail-open when cache/lock storage is unavailable and must never create an invisible permanent block. +7. Add cache-flock-backed active ban state with TTL plus a cache-backed active-ban index for Admin list rendering. The ban store and index must serialize index mutations, fail open when cache/lock storage is unavailable, and must never create an invisible permanent block. 8. Emit a persistent `security_signal_event` record when a ban is triggered, including whether the effective subject was `visitor` or `ip`, the TTL/escalation context, score summary, and safe references needed for Admin review without exposing raw IPs, raw visitor-cookie tokens, headers, secrets, or raw credentials. -9. Emit a Security signal when an Owner manually resets a ban. Reset signals invalidate earlier retained signals for that same subject and subject type for future score and escalation calculations, so the visitor/IP starts at zero after reset. +9. Emit a Security signal when an Owner manually resets a ban. Reset success must require observable reset-signal persistence and active cache-state release. Reset signals invalidate earlier retained signals for that same subject and subject type for future score and escalation calculations, so the visitor/IP starts at zero after reset. 10. Add enforcement early enough to run before controller/error-page rendering and before rate-limit buckets are consumed, but late enough that authenticated trusted users and trusted-user-owned API keys have been resolved and can bypass active Visitor/IP bans. Active temporary bans return the shared forced bare `403 Forbidden` response with `Retry-After`, generic message, and safe Request ID only. 11. Add Owner-gated Security settings UI fields for auto-ban enablement, trusted-user minimum access level, score threshold, and newly decided ban alerts. 12. Add the active-ban Admin list with subject type, safe subject label, created timestamp, TTL expiry, and detail link. The list and detail views use the existing non-configurable `admin.settings.security` ACL gate instead of a separate auto-ban gate. -13. Add the ban detail page with filtered Security signals explaining the decision and an Owner-gated manual reset button. -14. Register Admin API endpoints for listing active bans, reading one ban with retained signal context, and resetting one active ban under `/api/v1/admin/security/auto-bans`, using the same `admin.settings.security` ACL gate as the browser UI. +13. Add the ban detail page with retained Security signals explaining the decision and an Owner-gated manual reset button. Detail rows are newest-first and may include retained pre-reset history for review context, but historical rows must never displace newer post-reset/current evidence from the bounded view. +14. Register Admin API endpoints for listing active bans, reading one ban with retained signal context, and resetting one active ban under `/api/v1/admin/security/auto-bans`, using an explicit Owner minimum access level plus the same `admin.settings.security` ACL gate as the browser UI. ## Public interfaces and data decisions @@ -53,7 +53,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Initial signal weights are: error-hit `7`, suspicious probe path `100`, copied session or copied visitor-cookie `100`, and failed authentication `10`. That means roughly 15 error hits, one high-confidence probe plus another qualifying signal, one session-copy signal plus another qualifying signal, or 10 failed auth attempts can reach the Visitor threshold inside the one-hour window. - Triggered ban TTLs escalate globally as `1h`, `3h`, `24h`, and `7d`. - Escalation is derived from retained prior ban-triggered `security_signal_event` records for the same subject type. Visitor and IP escalation counts are separate because ban-trigger signals record whether the ban was for Visitor ID or IP. -- When one incoming Security signal makes both Visitor and IP scores eligible, create at most one new active ban for that signal and prefer the Visitor ban. Create an IP ban only when the IP score crosses the laxer threshold and no Visitor ban is created for the same evaluation. This preserves the IP defense against cookie/header mutation without unnecessarily broadening NAT impact. +- When one incoming Security signal makes both Visitor and IP scores eligible, create at most one new active ban for that signal and prefer the Visitor ban. Create an IP ban only when the IP score crosses the laxer threshold and no Visitor ban is created for the same evaluation. Trigger signals and Owner alerts are emitted only when the active ban state is newly created, not when a concurrent evaluator observes an already active ban. This preserves the IP defense against cookie/header mutation without unnecessarily broadening NAT impact. - Scoreable request signals should be recorded per evaluated source subject, not only as a primary subject with the IP bucket hidden in JSON context. The same request may therefore create paired Visitor/IP signal rows with a shared request ID or correlation context, while Admin detail views deduplicate them for human review. - Score aggregation is write-triggered, not request-triggered. After a scoreable `security_signal_event` insert succeeds, the same DB connection may query retained rows for the affected Visitor/IP subjects using the existing `subject_type`, `subject_identifier`, and `occurred_at` index, apply the latest reset cutoff and one-hour window, and decide whether to write a ban-trigger signal plus cache-flock state. Requests with no new scoreable signal only perform the cheap active-ban cache check. - Security-signal retention resets escalation naturally. Once prior ban-trigger signals expire or are reset, later bans start from the lower escalation tier again. @@ -65,13 +65,13 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Evaluated Security signals are limited to source-risk signals. Routine access logs, ordinary successful requests, expected validation failures, and login-required `401` responses do not contribute. Normal `404`, `403`, and `429` responses can still be weak signals because repeated misses, denials, or limiter collisions in a short window are source risk. - Honeypot/probe signals may carry high scores because they represent high-confidence scanner behavior. Ordinary error-page and rate-limit hits must stay low enough that a single legitimate mistake is harmless while repeated hits can still cross the threshold. - Trusted registered users are never auto-banned. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, the required setting also protects Owners from self-lockout through auto-ban. Valid API keys owned by trusted users inherit this bypass because the trusted user context has been resolved before active ban enforcement. -- The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. +- The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The normal login submission rendered from that recovery page must also be able to reach authentication so a trusted Owner context can be established. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. - Ban decisions follow the Security policy enforcement order so trusted-user context, trusted-user API-key context, active Admin/Owner session context, and recovery-login rendering are resolved before Visitor/IP bans can deny access, while active bans still run before error pages or rate-limit responses can be produced. - `/api/live/**` stays outside ordinary rate-limit rejection, but it is not an active auto-ban bypass. Once a Visitor/IP source is actively banned, live JSON endpoints must receive the same bare `403` enforcement unless the trusted-user or recovery-login policy applies. - Config keys must be registered through the settings/default provider so setup, missing database, or unavailable database states read safe defaults without touching Doctrine/DBAL. When the database is unavailable, signal persistence and score evaluation cannot happen, so auto-ban degrades fail-open. -- Active temporary ban responses use the shared `HttpErrorRenderer` forced bare response path: `403 Forbidden`, `Retry-After` when TTL is known, `Cache-Control: no-store`, safe Request ID/reference, and a generic message. They must not expose score values, rule names, subject keys, Visitor IDs, IP buckets, raw IP data, paths, headers, signal internals, or ban history. +- Active temporary ban responses use the shared `HttpErrorRenderer` forced bare response path: `403 Forbidden`, `Retry-After` when TTL is known, `Cache-Control: no-store`, safe Request ID/reference, and a generic message. They must not expose score values, rule names, subject keys, Visitor IDs, IP buckets, raw IP data, paths, headers, signal internals, or ban history, and must not record additional passive Security signals for the auto-ban `403`. - Cache-flock state is the active enforcement state holder. Explainability and escalation come from retained `security_signal_event` records, including ban-trigger and reset records, not from a separate durable ban table. -- The active-ban list is backed by the active ban store's cache index, while detail/reason evidence is backed by retained Security signals. If the cache index is unavailable or inconsistent, enforcement must fail open and the Admin UI should show a safe degraded-state diagnostic rather than inferring active bans from stale historical signals alone. +- The active-ban list is backed by the active ban store's cache index, while detail/reason evidence is backed by retained Security signals. If the cache index cannot be updated atomically with newly created active state, the new active state must be rolled back so enforcement does not create an unreviewable ban. If the cache index is unavailable or inconsistent, enforcement must fail open and the Admin UI should show a safe degraded-state diagnostic rather than inferring active bans from stale historical signals alone. - Ban-state keys come only from the shared subject/client-identity resolver and are limited to source subjects such as Visitor ID and stable IP bucket/HMAC. Raw IP strings, raw forwarding headers, raw API keys, credentials, usernames, emails, session IDs, visitor-cookie material, user IDs, and API-key identifiers must never become active auto-ban keys. - IP-derived evaluation and Admin review remain within existing IP-retention ceilings. IP ban TTLs must not exceed the seven-day maximum TTL and must never extend queryable IP-derived evidence beyond retention. - First implementation should use explicit subscriber priorities relative to existing security hooks: suspicious probe handling remains earliest, trusted-user/API-key context must be available before ordinary active-ban enforcement, and ordinary active-ban enforcement must run before `RateLimitRequestSubscriber::onKernelRequestOrdinary()` can consume buckets or return `429`. If a single priority cannot satisfy both browser-session and API-key context, split browser and API ban checks by request family while preserving this ordering. diff --git a/dev/manual/security-guard-snippets.md b/dev/manual/security-guard-snippets.md index bc144208..826ec772 100644 --- a/dev/manual/security-guard-snippets.md +++ b/dev/manual/security-guard-snippets.md @@ -63,13 +63,13 @@ Auto-ban enforcement is temporary, source-subject based, and fail-open: - Score aggregation runs only after a scoreable `security_signal_event` write. Ordinary requests perform only the active cache-state check. - Active ban state lives in cache-backed TTL entries with a cache-backed Admin index. Retained Security signals explain trigger and reset history; there is no durable ban table. - Visitor ID is the primary source subject. IP bucket/HMAC is evaluated separately with a laxer threshold multiplier. -- Trusted registered users at or above the configured trusted level, trusted-user-owned API keys, and the recovery login render path must bypass active Visitor/IP bans. -- Owner review surfaces for active bans use the non-configurable `admin.settings.security` ACL gate. The browser list/detail views and `/api/v1/admin/security/auto-bans` endpoints must reject delegated non-Owner admins. +- Trusted registered users at or above the configured trusted level, trusted-user-owned API keys, the recovery login render path, and the login submission rendered from that recovery path must bypass active Visitor/IP bans so trusted Owners can recover. +- Owner review surfaces for active bans use the non-configurable `admin.settings.security` ACL gate. The browser list/detail views and `/api/v1/admin/security/auto-bans` endpoints must reject delegated non-Owner admins, and the API endpoint metadata must advertise the same Owner minimum access level. - Newly decided ban alerts are configurable and enabled by default. When enabled, active Owner accounts receive a hidden warning with an action link to the active-ban list. - Disabling auto-ban stops score evaluation and active-ban enforcement immediately. Existing TTL cache entries may remain until they expire, but they must not block requests while the feature is disabled. - The Symfony `test` environment disables kernel-triggered auto-ban evaluation and enforcement unless a request explicitly opts in with `X-Auto-Ban-Testing: 1`, so broad controller suites that intentionally render many error responses do not poison shared test cache state. -- Active ban responses use the forced bare `403` path with `Retry-After`, `no-store`, a generic message, and a safe Request ID only. -- Manual reset clears active cache state, records a reset Security signal, and returns a success/error alert for the release workflow. Score and escalation queries ignore earlier evidence for the same subject after that reset. +- Active ban responses use the forced bare `403` path with `Retry-After`, `no-store`, a generic message, and a safe Request ID only. Auto-ban enforcement responses must not create additional passive Security signals. +- Manual reset clears active cache state, records a reset Security signal, and returns a success/error alert for the release workflow. Reset success requires both reset-signal persistence and active cache-state release. Score and escalation queries ignore earlier evidence for the same subject after that reset. ## Web server notes From f791538196e10d65d3c485061a07f2165676c9d5 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 15:18:11 +0200 Subject: [PATCH 18/55] Verify auto-ban rollback clears active state --- src/Security/AutoBan/AutoBanStore.php | 23 ++++++++++++++- tests/Security/AutoBan/AutoBanStoreTest.php | 32 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/Security/AutoBan/AutoBanStore.php b/src/Security/AutoBan/AutoBanStore.php index a9fb7ece..fddd4dcf 100644 --- a/src/Security/AutoBan/AutoBanStore.php +++ b/src/Security/AutoBan/AutoBanStore.php @@ -73,7 +73,7 @@ public function createOrReturnActive(AutoBanSubject $subject, int $ttlSeconds, a } if (!$this->upsertIndex($ban)) { - $this->cache->deleteItem($this->cacheKey($ban->key())); + $this->rollbackActiveState($ban); return null; } @@ -193,6 +193,27 @@ private function cacheKey(string $key): string return self::KEY_PREFIX.$key; } + private function rollbackActiveState(ActiveAutoBan $ban): void + { + $cacheKey = $this->cacheKey($ban->key()); + if (!$this->cache->deleteItem($cacheKey)) { + $this->reportStorage('ban_rollback_delete', new \RuntimeException('Active auto-ban rollback cache delete failed.'), ['active_ban_key' => $ban->key()]); + } + + if (null === $this->activeByKey($ban->key())) { + return; + } + + $item = $this->cache->getItem($cacheKey); + $payload = $ban->toArray(); + $payload['expires_at'] = $this->clock->now()->modify('-1 second')->format('Y-m-d H:i:s'); + $item->set($payload); + $item->expiresAfter($ban->ttlSeconds()); + if (!$this->cache->save($item) || null !== $this->activeByKey($ban->key())) { + $this->reportStorage('ban_rollback_verify', new \RuntimeException('Active auto-ban cache entry remained after rollback.'), ['active_ban_key' => $ban->key()]); + } + } + private function upsertIndex(ActiveAutoBan $ban): bool { return $this->updateIndex(static function (array $index) use ($ban): array { diff --git a/tests/Security/AutoBan/AutoBanStoreTest.php b/tests/Security/AutoBan/AutoBanStoreTest.php index dfb0c0af..4114810f 100644 --- a/tests/Security/AutoBan/AutoBanStoreTest.php +++ b/tests/Security/AutoBan/AutoBanStoreTest.php @@ -38,6 +38,17 @@ public function testResetFailsWhenActiveCacheDeleteFails(): void self::assertNotNull($store->active($subject)); } + public function testBanRollbackClearsActiveStateWhenIndexAndDeleteFail(): void + { + $cache = new IndexAndDeleteFailingAutoBanCache(); + $store = new AutoBanStore($cache, new LockFactory(new InMemoryStore()), clock: new MockClock('2026-06-18 12:00:00')); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-index-delete-failure'); + + self::assertNull($store->ban($subject, 3600)); + self::assertNull($store->active($subject)); + self::assertSame([], $store->activeBans()); + } + public function testCreateOrReturnActiveMarksExistingBanAsNotCreated(): void { $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: new MockClock('2026-06-18 12:00:00')); @@ -77,3 +88,24 @@ public function deleteItem(mixed $key): bool return parent::deleteItem($key); } } + +final class IndexAndDeleteFailingAutoBanCache extends ArrayAdapter +{ + public function save(CacheItemInterface $item): bool + { + if ('security.auto_ban.index.v1' === $item->getKey()) { + return false; + } + + return parent::save($item); + } + + public function deleteItem(mixed $key): bool + { + if (is_string($key) && str_starts_with($key, 'security.auto_ban.active.')) { + return false; + } + + return parent::deleteItem($key); + } +} From ffe62914f9913c80db294c5f6e2a4ad1d26d68f4 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 15:20:21 +0200 Subject: [PATCH 19/55] Centralize ignorable request path skips --- src/Core/Log/AccessLogSubscriber.php | 15 +++--- .../Routing/IgnorableRequestPathMatcher.php | 50 +++++++++++++++++++ .../Abuse/PassiveAbuseSignalSubscriber.php | 10 ++-- .../AutoBan/AutoBanRequestSubscriber.php | 11 ++-- .../RateLimit/RateLimitRequestSubscriber.php | 8 ++- .../IgnorableRequestPathMatcherTest.php | 40 +++++++++++++++ .../PassiveAbuseSignalSubscriberTest.php | 28 +++++++++++ .../RateLimitRequestSubscriberTest.php | 8 +++ 8 files changed, 150 insertions(+), 20 deletions(-) create mode 100644 src/Core/Routing/IgnorableRequestPathMatcher.php create mode 100644 tests/Core/Routing/IgnorableRequestPathMatcherTest.php diff --git a/src/Core/Log/AccessLogSubscriber.php b/src/Core/Log/AccessLogSubscriber.php index 61bb4717..abba055d 100644 --- a/src/Core/Log/AccessLogSubscriber.php +++ b/src/Core/Log/AccessLogSubscriber.php @@ -8,6 +8,7 @@ use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\Message\MessageReporterInterface; +use App\Core\Routing\IgnorableRequestPathMatcher; use App\Core\Statistics\AccessStatisticsRecorderInterface; use App\Core\Statistics\VisitorIdGenerator; use App\Database\DatabaseReadyState; @@ -19,6 +20,8 @@ final readonly class AccessLogSubscriber implements EventSubscriberInterface { + private IgnorableRequestPathMatcher $ignorablePaths; + public function __construct( private AccessLoggerInterface $accessLogger, private AccessStatisticsRecorderInterface $accessStatisticsRecorder, @@ -26,7 +29,9 @@ public function __construct( private VisitorIdGenerator $visitorIdGenerator, private ?MessageReporterInterface $messageReporter = null, private ?DatabaseReadyState $databaseReadyState = null, + ?IgnorableRequestPathMatcher $ignorablePaths = null, ) { + $this->ignorablePaths = $ignorablePaths ?? new IgnorableRequestPathMatcher(); } public static function getSubscribedEvents(): array @@ -71,20 +76,14 @@ public function onKernelResponse(ResponseEvent $event): void private function shouldSkipAccessLog(string $path): bool { - return str_starts_with($path, '/_profiler') - || str_starts_with($path, '/_wdt') - || str_starts_with($path, '/assets/') - || str_starts_with($path, '/build/'); + return $this->ignorablePaths->matches($path); } private function shouldSkipStatistics(string $path): bool { return $this->databaseIsNotReady() || str_starts_with($path, '/setup') - || str_starts_with($path, '/_profiler') - || str_starts_with($path, '/_wdt') - || str_starts_with($path, '/assets/') - || str_starts_with($path, '/build/'); + || $this->ignorablePaths->matches($path); } private function databaseIsNotReady(): bool diff --git a/src/Core/Routing/IgnorableRequestPathMatcher.php b/src/Core/Routing/IgnorableRequestPathMatcher.php new file mode 100644 index 00000000..b76004e6 --- /dev/null +++ b/src/Core/Routing/IgnorableRequestPathMatcher.php @@ -0,0 +1,50 @@ +paths = $paths ?? new PathScopeMatcher(); + } + + public function matches(string $path): bool + { + return $this->paths->matchesAnyPrefix($path, ...self::PREFIXES) + || in_array($path, self::EXACT_PATHS, true); + } +} diff --git a/src/Security/Abuse/PassiveAbuseSignalSubscriber.php b/src/Security/Abuse/PassiveAbuseSignalSubscriber.php index 0d7a6586..0557447a 100644 --- a/src/Security/Abuse/PassiveAbuseSignalSubscriber.php +++ b/src/Security/Abuse/PassiveAbuseSignalSubscriber.php @@ -6,6 +6,7 @@ use App\Core\Access\AccessLevel; use App\Core\Log\AccessRequestMetadata; +use App\Core\Routing\IgnorableRequestPathMatcher; use App\Security\AutoBan\AutoBanPolicy; use App\Security\AutoBan\AutoBanRequestSubscriber; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -15,12 +16,16 @@ final readonly class PassiveAbuseSignalSubscriber implements EventSubscriberInterface { + private IgnorableRequestPathMatcher $ignorablePaths; + public function __construct( private AbuseRequestInspector $inspector, private SecuritySignalRecorder $signalRecorder, private AccessRequestMetadata $accessRequestMetadata, private ?AutoBanPolicy $autoBanPolicy = null, + ?IgnorableRequestPathMatcher $ignorablePaths = null, ) { + $this->ignorablePaths = $ignorablePaths ?? new IgnorableRequestPathMatcher(); } public static function getSubscribedEvents(): array @@ -159,9 +164,6 @@ private function trustedContext(AbuseSubjectResolution $subjects): bool 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/'); + return $this->ignorablePaths->matches($path); } } diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index fa0105bc..02bf9237 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -8,7 +8,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\IgnorableRequestPathMatcher; use App\Security\SecurityMessageCode; use App\Security\SecurityMessageKey; use App\Security\Abuse\AbuseRequestInspector; @@ -30,7 +30,7 @@ { public const PASSIVE_SIGNAL_SKIP_ATTRIBUTE = '_system_auto_ban_response'; - private PathScopeMatcher $paths; + private IgnorableRequestPathMatcher $ignorablePaths; public function __construct( private AbuseRequestInspector $inspector, @@ -41,9 +41,9 @@ public function __construct( private string $environment = 'prod', private ?MessageReporterInterface $messageReporter = null, private ClockInterface $clock = new NativeClock(), - ?PathScopeMatcher $paths = null, + ?IgnorableRequestPathMatcher $ignorablePaths = null, ) { - $this->paths = $paths ?? new PathScopeMatcher(); + $this->ignorablePaths = $ignorablePaths ?? new IgnorableRequestPathMatcher(); } public static function getSubscribedEvents(): array @@ -144,8 +144,7 @@ private function banResponse(Request $request, ActiveAutoBan $ban): Response private function excludedRequest(Request $request): bool { - return $this->paths->matchesAnyPrefix($request->getPathInfo(), '/assets', '/build', '/_profiler', '/_wdt') - || in_array($request->getPathInfo(), ['/favicon.ico', '/robots.txt'], true); + return $this->ignorablePaths->matches($request->getPathInfo()); } private function enabledForRequest(Request $request): bool diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php index 1e02152c..addf4e98 100644 --- a/src/Security/RateLimit/RateLimitRequestSubscriber.php +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -5,6 +5,7 @@ namespace App\Security\RateLimit; use App\Core\Routing\PathScopeMatcher; +use App\Core\Routing\IgnorableRequestPathMatcher; use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Setup\SetupCompletionMarker; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -17,6 +18,7 @@ { private SuspiciousProbePathMatcher $probePathMatcher; private PathScopeMatcher $paths; + private IgnorableRequestPathMatcher $ignorablePaths; public function __construct( private RateLimitEnforcer $enforcer, @@ -26,9 +28,11 @@ public function __construct( private string $projectDir, ?SuspiciousProbePathMatcher $probePathMatcher = null, ?PathScopeMatcher $paths = null, + ?IgnorableRequestPathMatcher $ignorablePaths = null, ) { $this->probePathMatcher = $probePathMatcher ?? new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS); $this->paths = $paths ?? new PathScopeMatcher(); + $this->ignorablePaths = $ignorablePaths ?? new IgnorableRequestPathMatcher($this->paths); } public static function getSubscribedEvents(): array @@ -101,8 +105,8 @@ private function apply(RequestEvent $event, RateLimitEnforcementStage $stage, bo private function excludedRequest(Request $request): bool { - return $this->paths->matchesAnyPrefix($request->getPathInfo(), '/api/live', '/assets', '/build', '/_profiler', '/_wdt') - || in_array($request->getPathInfo(), ['/favicon.ico', '/robots.txt'], true); + return $this->paths->matchesAnyPrefix($request->getPathInfo(), '/api/live') + || $this->ignorablePaths->matches($request->getPathInfo()); } private function setupApplyRequest(Request $request): bool diff --git a/tests/Core/Routing/IgnorableRequestPathMatcherTest.php b/tests/Core/Routing/IgnorableRequestPathMatcherTest.php new file mode 100644 index 00000000..750dfd2f --- /dev/null +++ b/tests/Core/Routing/IgnorableRequestPathMatcherTest.php @@ -0,0 +1,40 @@ + + */ + public static function pathCases(): iterable + { + yield 'assets root' => ['/assets', true]; + yield 'assets child' => ['/assets/app.css', true]; + yield 'assets sibling' => ['/assets-preview', false]; + yield 'build root' => ['/build', true]; + yield 'profiler child' => ['/_profiler/123', true]; + yield 'toolbar sibling' => ['/_wdtfoo', false]; + yield 'favicon' => ['/favicon.ico', true]; + yield 'robots' => ['/robots.txt', true]; + yield 'touch icon' => ['/apple-touch-icon.png', true]; + yield 'site manifest' => ['/site.webmanifest', true]; + yield 'sitemap' => ['/sitemap.xml', true]; + yield 'well-known security' => ['/.well-known/security.txt', true]; + yield 'well-known webfinger' => ['/.well-known/webfinger', true]; + yield 'well-known unknown child' => ['/.well-known/random-scanner', false]; + yield 'application route' => ['/missing', false]; + } + + #[DataProvider('pathCases')] + public function testItMatchesIgnorableStaticAndToolingPaths(string $path, bool $expected): void + { + self::assertSame($expected, (new IgnorableRequestPathMatcher())->matches($path)); + } +} diff --git a/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php index 01145f02..909d6d2b 100644 --- a/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php +++ b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php @@ -96,6 +96,34 @@ public function testItDoesNotRecordOrdinaryNavigationSignals(): void self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); } + public function testItDoesNotRecordMissingWellKnownStaticPaths(): 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(), + ); + + foreach (['/favicon.ico', '/apple-touch-icon.png', '/.well-known/security.txt'] as $path) { + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + Request::create($path, server: ['REMOTE_ADDR' => '203.0.113.10']), + HttpKernelInterface::MAIN_REQUEST, + new Response('', 404), + )); + } + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + public function testItRecordsErrorStatusSignalsButExcludesLoginRequired401(): void { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php index 8e8afbd2..fa25c6ac 100644 --- a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -11,6 +11,7 @@ use App\Core\Log\AccessRequestMetadata; use App\Core\Message\Message; use App\Core\Message\MessageReporterInterface; +use App\Core\Routing\IgnorableRequestPathMatcher; use App\Core\Routing\PathScopeMatcher; use App\Core\Statistics\VisitorIdGenerator; use App\Security\Abuse\AbuseRequestInspector; @@ -84,6 +85,9 @@ public static function excludedPathCases(): iterable yield 'assets sibling' => ['/assets-preview', false]; yield 'build child' => ['/build/app.js', true]; yield 'build sibling' => ['/builder', false]; + yield 'favicon' => ['/favicon.ico', true]; + yield 'touch icon' => ['/apple-touch-icon.png', true]; + yield 'well-known security' => ['/.well-known/security.txt', true]; yield 'profiler root' => ['/_profiler', true]; yield 'profiler child' => ['/_profiler/123', true]; yield 'profiler sibling' => ['/_profilerfoo', false]; @@ -97,6 +101,8 @@ public function testExcludedPathUsesSegmentBoundaries(string $path, bool $exclud $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); $paths->setValue($subscriber, new PathScopeMatcher()); + $ignorablePaths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'ignorablePaths'); + $ignorablePaths->setValue($subscriber, new IgnorableRequestPathMatcher()); $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); self::assertSame($excluded, $method->invoke($subscriber, Request::create($path))); @@ -107,6 +113,8 @@ public function testExcludedRequestDoesNotUseLocalizedTechnicalPathSegments(): v $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); $paths->setValue($subscriber, new PathScopeMatcher()); + $ignorablePaths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'ignorablePaths'); + $ignorablePaths->setValue($subscriber, new IgnorableRequestPathMatcher()); $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); $localized = Request::create('/de/api/live/status'); $localized->attributes->set('_locale', 'de'); From 78058ee66806f20d5a809137a5da236e7412153d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 15:21:51 +0200 Subject: [PATCH 20/55] Require recovery marker for login ban bypass --- src/Controller/SecurityController.php | 1 + .../AutoBan/AutoBanRequestSubscriber.php | 22 ++++++++++++--- templates/frontend/user/login.html.twig | 3 +++ tests/Controller/SecurityControllerTest.php | 11 ++++++++ .../AutoBan/AutoBanRequestSubscriberTest.php | 27 +++++++++++++++++-- 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 22620b76..b9177601 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -27,6 +27,7 @@ public function login(AuthenticationUtils $authenticationUtils, Request $request 'return_to' => $this->returnTo($request), 'registration_enabled' => $this->config->registrationEnabled(), 'account_closed' => '1' === $request->query->get('account_closed'), + 'auto_ban_recovery_login' => '1' === (string) $request->query->get('bypass', ''), ]); } diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index 02bf9237..c2a03a22 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -24,11 +24,15 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Throwable; final readonly class AutoBanRequestSubscriber implements EventSubscriberInterface { public const PASSIVE_SIGNAL_SKIP_ATTRIBUTE = '_system_auto_ban_response'; + public const RECOVERY_LOGIN_TOKEN_FIELD = '_auto_ban_recovery_token'; + public const RECOVERY_LOGIN_TOKEN_ID = 'auto_ban_recovery_login'; private IgnorableRequestPathMatcher $ignorablePaths; @@ -42,6 +46,7 @@ public function __construct( private ?MessageReporterInterface $messageReporter = null, private ClockInterface $clock = new NativeClock(), ?IgnorableRequestPathMatcher $ignorablePaths = null, + private ?CsrfTokenManagerInterface $csrfTokens = null, ) { $this->ignorablePaths = $ignorablePaths ?? new IgnorableRequestPathMatcher(); } @@ -66,7 +71,7 @@ public function onKernelRequest(RequestEvent $event): void try { $inspection = $this->inspector->inspect($request); - if ($this->recoveryRequest($inspection['profile']) || $this->trustedContext($inspection['subjects']->subjects())) { + if ($this->recoveryRequest($request, $inspection['profile']) || $this->trustedContext($inspection['subjects']->subjects())) { return; } @@ -119,7 +124,7 @@ private function trustedContext(array $subjects): bool return false; } - private function recoveryRequest(AbuseRequestProfile $profile): bool + private function recoveryRequest(Request $request, AbuseRequestProfile $profile): bool { if (RequestIntent::RecoveryLogin === $profile->intent()) { return true; @@ -127,7 +132,18 @@ private function recoveryRequest(AbuseRequestProfile $profile): bool return RequestIntent::Login === $profile->intent() && 'POST' === $profile->method() - && in_array($profile->route(), ['user_login', 'n/a'], true); + && in_array($profile->route(), ['user_login', 'n/a'], true) + && $this->validRecoveryLoginToken($request); + } + + private function validRecoveryLoginToken(Request $request): bool + { + $token = $request->request->get(self::RECOVERY_LOGIN_TOKEN_FIELD); + + return is_string($token) + && '' !== $token + && $this->csrfTokens instanceof CsrfTokenManagerInterface + && $this->csrfTokens->isTokenValid(new CsrfToken(self::RECOVERY_LOGIN_TOKEN_ID, $token)); } private function banResponse(Request $request, ActiveAutoBan $ban): Response diff --git a/templates/frontend/user/login.html.twig b/templates/frontend/user/login.html.twig index 40029a24..eac69193 100644 --- a/templates/frontend/user/login.html.twig +++ b/templates/frontend/user/login.html.twig @@ -35,6 +35,9 @@ {% if return_to|default(null) %} {% endif %} + {% if auto_ban_recovery_login|default(false) %} + + {% endif %} {% include '@frontend/partials/forms/fields/captcha.html.twig' %} {% include '@frontend/partials/forms/fields/submit.html.twig' with {label: 'ui.user.login.submit'|trans} only %} diff --git a/tests/Controller/SecurityControllerTest.php b/tests/Controller/SecurityControllerTest.php index 17553129..4f2dd5a9 100644 --- a/tests/Controller/SecurityControllerTest.php +++ b/tests/Controller/SecurityControllerTest.php @@ -26,11 +26,22 @@ public function testLoginRouteRendersLoginForm(): void self::assertSelectorTextContains('h1', 'Sign in'); self::assertSelectorExists('form[action="/user/login"][method="post"]'); self::assertSelectorExists('input[name="_csrf_token"]'); + self::assertSelectorNotExists('input[name="_auto_ban_recovery_token"]'); self::assertSelectorTextContains('a[href="/user/reset-password"]', 'Forgot password?'); self::assertSelectorNotExists('.system-frontend-error-reference'); self::assertSelectorNotExists('a[href="/user/register"]'); } + public function testRecoveryLoginRouteRendersAutoBanRecoveryMarker(): void + { + $client = self::createClient(); + $client->request('GET', '/user/login?bypass=1'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('form[action="/user/login"][method="post"]'); + self::assertSelectorExists('input[name="_auto_ban_recovery_token"]'); + } + public function testLoginFormAuthenticatesUserAccount(): void { $client = self::createClient(); diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index 54afc020..be940df2 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -34,6 +34,7 @@ use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\Store\InMemoryStore; +use Symfony\Component\Security\Csrf\CsrfTokenManager; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Twig\Environment; @@ -80,7 +81,27 @@ public function testRecoveryLoginBypassIsReachableDespiteActiveBan(): void self::assertNull($event->getResponse()); } - public function testLoginSubmissionsCanEstablishTrustedRecoveryContextDespiteActiveBan(): void + public function testRecoveryLoginSubmissionsCanEstablishTrustedContextDespiteActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $csrfTokens = new CsrfTokenManager(); + $request = Request::create('/user/login', 'POST', [ + 'username' => 'owner', + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => (string) $csrfTokens->getToken(AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_ID), + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock, csrfTokens: $csrfTokens)->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testOrdinaryLoginSubmissionsDoNotBypassActiveBan(): void { $clock = new MockClock('2026-06-18 12:00:00'); $visitorIds = new VisitorIdGenerator('test-secret'); @@ -93,7 +114,7 @@ public function testLoginSubmissionsCanEstablishTrustedRecoveryContextDespiteAct $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); - self::assertNull($event->getResponse()); + self::assertSame(403, $event->getResponse()?->getStatusCode()); } public function testLiveEndpointsDoNotBypassActiveBans(): void @@ -144,6 +165,7 @@ private function subscriber( AutoBanStore $store, MockClock $clock, ?TokenStorage $tokenStorage = null, + ?CsrfTokenManager $csrfTokens = null, ): AutoBanRequestSubscriber { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); @@ -159,6 +181,7 @@ private function subscriber( $this->renderer(), new AccessRequestMetadata(), clock: $clock, + csrfTokens: $csrfTokens, ); } From f1cca1e14f6d05879dfac50895e2fd7e06ca32ab Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 15:24:41 +0200 Subject: [PATCH 21/55] Fail open auto-ban when config is unavailable --- src/Security/AutoBan/AutoBanPolicy.php | 3 +- src/Setup/SetupDefaultSeed.php | 10 +++++ .../Core/Config/CoreSettingsRegistryTest.php | 4 +- tests/Security/AutoBan/AutoBanPolicyTest.php | 45 +++++++++++++++++++ .../AutoBan/AutoBanRequestSubscriberTest.php | 5 ++- .../AutoBan/AutoBanSignalEvaluatorTest.php | 5 ++- tests/Setup/SetupDefaultSeedTest.php | 9 ++++ 7 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 tests/Security/AutoBan/AutoBanPolicyTest.php diff --git a/src/Security/AutoBan/AutoBanPolicy.php b/src/Security/AutoBan/AutoBanPolicy.php index abfc49b8..802631d3 100644 --- a/src/Security/AutoBan/AutoBanPolicy.php +++ b/src/Security/AutoBan/AutoBanPolicy.php @@ -14,7 +14,8 @@ public const SCORE_THRESHOLD_KEY = 'security.auto_ban.score_threshold'; public const NEW_BAN_OWNER_ALERTS_KEY = 'security.auto_ban.new_ban_owner_alerts'; - public const DEFAULT_ENABLED = true; + public const DEFAULT_ENABLED = false; + public const SETUP_ENABLED = true; public const DEFAULT_NEW_BAN_OWNER_ALERTS = true; public const DEFAULT_TRUSTED_ACCESS_LEVEL = AccessLevel::MANAGER; public const DEFAULT_SCORE_THRESHOLD = 100; diff --git a/src/Setup/SetupDefaultSeed.php b/src/Setup/SetupDefaultSeed.php index bee9453d..86c540bd 100644 --- a/src/Setup/SetupDefaultSeed.php +++ b/src/Setup/SetupDefaultSeed.php @@ -18,6 +18,7 @@ use App\Localization\LocaleToken; use App\Scheduler\SchedulerSettings; use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\AutoBan\AutoBanPolicy; use App\Security\UserFlowConfig; final readonly class SetupDefaultSeed @@ -54,6 +55,10 @@ 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' => SuspiciousProbePathMatcher::PATTERNS_KEY, 'value' => $this->setting($input, SuspiciousProbePathMatcher::PATTERNS_KEY, SuspiciousProbePathMatcher::defaultPatternText()), 'type' => ConfigValueType::String], + ['key' => AutoBanPolicy::ENABLED_KEY, 'value' => $this->setupSetting($input, AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED), 'type' => ConfigValueType::Boolean], + ['key' => AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 'value' => $this->setting($input, AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL), 'type' => ConfigValueType::Integer], + ['key' => AutoBanPolicy::SCORE_THRESHOLD_KEY, 'value' => $this->setting($input, AutoBanPolicy::SCORE_THRESHOLD_KEY, AutoBanPolicy::DEFAULT_SCORE_THRESHOLD), 'type' => ConfigValueType::Integer], + ['key' => AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY, 'value' => $this->setting($input, AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY, AutoBanPolicy::DEFAULT_NEW_BAN_OWNER_ALERTS), 'type' => ConfigValueType::Boolean], ['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], @@ -75,6 +80,11 @@ private function setting(SetupInput $input, string $key, mixed $default): mixed return $input->siteSettings()[$key] ?? $this->default($key, $default); } + private function setupSetting(SetupInput $input, string $key, mixed $default): mixed + { + return $input->siteSettings()[$key] ?? $default; + } + private function default(string $key, mixed $fallback): mixed { if (null !== $this->configDefaults && $this->configDefaults->hasDefault($key)) { diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index 9a03eca9..fc6ca5ad 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -85,7 +85,7 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void 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::assertTrue($security[4]->defaultValue()); + self::assertFalse($security[4]->defaultValue()); self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, $security[5]->defaultValue()); self::assertSame(FormInputType::Select, $security[5]->formField()->inputType()); self::assertSame(AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, $security[6]->defaultValue()); @@ -153,7 +153,7 @@ public function testItExposesPersistedDefaultsForRuntimeConfigFallbacks(): void 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::assertTrue($provider->defaultValue(AutoBanPolicy::ENABLED_KEY)); + self::assertFalse($provider->defaultValue(AutoBanPolicy::ENABLED_KEY)); self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, $provider->defaultValue(AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY)); self::assertSame(AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, $provider->defaultValue(AutoBanPolicy::SCORE_THRESHOLD_KEY)); self::assertTrue($provider->defaultValue(AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY)); diff --git a/tests/Security/AutoBan/AutoBanPolicyTest.php b/tests/Security/AutoBan/AutoBanPolicyTest.php new file mode 100644 index 00000000..2fccb4ce --- /dev/null +++ b/tests/Security/AutoBan/AutoBanPolicyTest.php @@ -0,0 +1,45 @@ + 'pdo_sqlite', 'memory' => true]), + databaseReadyState: new DatabaseReadyState(new SetupCompletionMarker(), sys_get_temp_dir().'/missing-auto-ban-config', 'test'), + defaultProvider: new CoreConfigDefaultProvider(new CoreSettingsRegistry( + new TranslationLanguageCatalog($projectDir), + new SystemPackageMetadataProvider($projectDir), + )), + )); + + self::assertFalse($policy->enabled()); + } + + public function testItUsesPersistedSetupEnabledValueWhenConfigStorageIsAvailable(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED, ConfigValueType::Boolean); + + self::assertTrue((new AutoBanPolicy($config))->enabled()); + } +} diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index be940df2..dec78199 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -7,6 +7,7 @@ use App\Content\Read\PublishedContentResolver; use App\Content\Render\ContentFieldsetRenderer; use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; use App\Core\Log\AccessRequestMetadata; use App\Core\Statistics\VisitorIdGenerator; use App\Entity\UserAccount; @@ -169,6 +170,8 @@ private function subscriber( ): AutoBanRequestSubscriber { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED, ConfigValueType::Boolean); return new AutoBanRequestSubscriber( new AbuseRequestInspector( @@ -176,7 +179,7 @@ private function subscriber( new RequestIntentClassifier(), new ActionCostCatalogue(), ), - new AutoBanPolicy(new Config($connection)), + new AutoBanPolicy($config), $store, $this->renderer(), new AccessRequestMetadata(), diff --git a/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php b/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php index a2438fbb..da064ca9 100644 --- a/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php +++ b/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Security\AutoBan; use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; use App\Core\Message\Message; use App\Core\Message\MessageReporterInterface; use App\Core\Log\DatabaseLogRetentionPolicy; @@ -99,12 +100,14 @@ public function testSameEvaluationPrefersVisitorBanOverIpBan(): void private function stack(): array { $connection = $this->connection(); + $config = new Config($connection); + $config->set(AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED, ConfigValueType::Boolean); $clock = new MockClock('2026-06-18 12:00:00'); $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); $evaluator = new AutoBanSignalEvaluator( $connection, new DatabaseLogRetentionPolicy($connection), - new AutoBanPolicy(new Config($connection)), + new AutoBanPolicy($config), new AutoBanScoreCatalogue(), $store, clock: $clock, diff --git a/tests/Setup/SetupDefaultSeedTest.php b/tests/Setup/SetupDefaultSeedTest.php index c73e11fe..508003ac 100644 --- a/tests/Setup/SetupDefaultSeedTest.php +++ b/tests/Setup/SetupDefaultSeedTest.php @@ -15,6 +15,7 @@ use App\Setup\SetupInput; use App\Scheduler\SchedulerSettings; use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\AutoBan\AutoBanPolicy; use App\Security\UserFlowConfig; use PHPUnit\Framework\TestCase; @@ -40,6 +41,10 @@ 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::assertTrue($settings[AutoBanPolicy::ENABLED_KEY]); + self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, $settings[AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY]); + self::assertSame(AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, $settings[AutoBanPolicy::SCORE_THRESHOLD_KEY]); + self::assertTrue($settings[AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY]); self::assertSame((new AdminFeatureDefaults())->overrides(), $settings[AdminFeatureOverrideStore::CONFIG_KEY]); } @@ -81,6 +86,10 @@ public function testEverySetupConfigKeyHasACentralDefaultExceptSetupInputValues( DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, SuspiciousProbePathMatcher::PATTERNS_KEY, + AutoBanPolicy::ENABLED_KEY, + AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, + AutoBanPolicy::SCORE_THRESHOLD_KEY, + AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY, \App\Core\Statistics\AccessStatisticsPolicy::ENABLED_KEY, \App\Core\Statistics\AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY, MaxMindGeoIpConfig::ENABLED_KEY, From e3378b820466b31af63cc6040c757663a28bf7c2 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 15:26:37 +0200 Subject: [PATCH 22/55] Document second auto-ban review fixes --- dev/CLASSMAP.md | 8 ++++---- dev/WORKLOG.md | 1 + dev/draft/security-hardening/auto-ban.md | 8 ++++---- dev/draft/security-hardening/policy-defaults.md | 6 +++--- dev/manual/security-guard-snippets.md | 4 +++- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 0d30d397..ce2ccf32 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 raw technical route scopes and request-aware URL locale-prefix stripping only for locale-prefix UI/account scopes, so protected prefixes such as `/api/v1`, `/cron`, generated assets, profiler, toolbar paths, access-log surfaces, request intents, and abuse-subject workflows cannot accidentally match lookalike public content paths or localized non-technical aliases. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Routing/PathScopeMatcherTest.php`, `tests/Core/Routing/RequestPathResolverTest.php` | +| Service | `App\Core\Routing\PathScopeMatcher`, `App\Core\Routing\RequestPathResolver`, `App\Core\Routing\IgnorableRequestPathMatcher` | Shared segment-bound path-scope matchers for raw technical route scopes, request-aware URL locale-prefix stripping only for locale-prefix UI/account scopes, and static/tooling/well-known request paths that should not spend rate-limit budget, access-statistics rows, active-ban checks, or passive error-status Security signals, so protected prefixes such as `/api/v1`, `/cron`, generated assets, profiler, toolbar paths, access-log surfaces, request intents, and abuse-subject workflows cannot accidentally match lookalike public content paths or localized non-technical aliases. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Routing/PathScopeMatcherTest.php`, `tests/Core/Routing/RequestPathResolverTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Service/contract/controller/Twig | `App\Privacy\Cookie\CookieConsentDefinition`, `App\Privacy\Cookie\CookieConsentProviderInterface`, `App\Privacy\Cookie\CookieConsentRegistry`, `App\Privacy\Cookie\CookieConsentManager`, `App\Privacy\Cookie\ConsentCookieJar`, `App\Privacy\Cookie\CookieConsentResponseSubscriber`, `App\Privacy\Cookie\CookieConsentTwigExtension`, `App\Controller\CookieConsentController`, `templates/components/CookieConsent.html.twig`, `assets/controllers/cookie_consent_controller.js` | Provides a package-extendable cookie consent registry with duplicate-name rejection, package-load duplicate/core-cookie collision faulting, HTTP(S)/relative-only optional-cookie privacy links, central safe cookie get/set gate with registered cookie identity and policy-attribute enforcement, very-late response-time removal of registered optional cookies without stored consent while preserving explicit clear-cookie headers, explicit expiration of every rejected optional cookie, DNT/GPC-aware defaults, visitor-bound stateless HMAC CSRF protection that does not create anonymous sessions, signed TTL-validated system-owned consent-cookie persistence, selected optional-cookie state for later edits, safe relative-only consent redirects, reusable `cookie_consent_trigger_attributes()` links, and a frontend banner/overlay that only auto-opens when optional cookies are registered without stored consent. | `dev/draft/0.2.x-SecurityAccessControl.md`, `docs/**` | `tests/assets/controller_foundation.test.mjs`, `tests/Privacy/Cookie/CookieConsentManagerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php` | | API foundation/security | `App\Api\ApiFeaturePolicy`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Api\Security\ApiRequestMethodPolicy`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber` | Provides the versioned `/api/v1` runtime boundary with optional stateless Bearer API-key authentication, config-controlled availability and CORS handling that keeps anonymous preflights cheap while letting actual `Authorization` preflights reach rate-limit handling by requested method, request-scoped authenticated or anonymous API context, shared effective-method resolution for credentialed/read-only/CORS preflight decisions, endpoint-derived minimum-access checks, JSON content-type enforcement, setup/maintenance/database/disabled `503` JSON responses, trace headers, localized Message-layer data/error responses, JSON object request parsing, and shared list query normalization. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Api/Security/ApiRequestMethodPolicyTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | | API 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` | @@ -195,14 +195,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, exact-segment surface detection that strips language prefixes only for actual route locale attributes or enabled content route prefixes, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel and the database lookup projection; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | +| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, exact-segment surface detection that strips language prefixes only for actual route locale attributes or enabled content route prefixes, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel and the database lookup projection; `AccessLogSubscriber` skips shared static/tooling/well-known paths, refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | | Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, stream archives without materializing them in memory, write atomically to `var/geoip2`, reject unsafe archive member paths, preserve the previous database on failure where possible, bound stored location labels, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php`, `tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | | Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel and the database lookup projection; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, package install verification, package lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | -| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\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 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, records source-subject Visitor/IP signals for high-signal probes and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/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 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, records source-subject Visitor/IP signals for high-signal probes and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS`, 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/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets trusted users and recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, and records Owner reset signals that clear active cache state and invalidate earlier scoring evidence. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets trusted users plus recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, and records Owner reset signals that clear active cache state and invalidate earlier scoring evidence. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 300da741..3801d04b 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -99,6 +99,7 @@ - Review-hardened active-ban enforcement so `/api/live/**` remains outside ordinary rate-limit `429` handling but no longer bypasses an already active auto-ban, and added a `request_id`/`reason_code` Security-signal index for the Visitor-over-IP ban dedupe query. - Verification: `php -l` on changed PHP entry points passed; `php bin/console lint:container` passed; focused AutoBan/API/settings/message PHPUnit groups passed; full `php bin/phpunit` passed with 1631 tests and 10708 assertions; `bin/lint --diff` plus explicit lint for new auto-ban files passed. - Addressed first Cloud Review round with separate reviewable commits: recovery login submissions can authenticate despite source bans; active-ban index updates are serialized and roll back unindexed active state; failed cache deletes make reset fail; reset success requires reset-signal persistence; concurrent evaluators emit trigger signals/Owner alerts only for newly created bans; detail views are newest-first while retaining history; auto-ban `403` responses do not create passive signals; and auto-ban API endpoints now advertise Owner-level access before the handler ACL gate. +- Addressed second Cloud Review round with separate reviewable commits: index-write rollback now verifies active-state removal and falls back to an expired payload when cache deletion fails; shared ignorable static/tooling/well-known path matching prevents routine missing favicon/robots/touch-icon/discovery requests from creating passive `404` Security signals; login ban bypass now requires the CSRF-backed recovery marker rendered by `GET /user/login?bypass=1`; and auto-ban enablement fails open when config storage is unavailable while setup still seeds completed installations as enabled. ### 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 a86bcd6d..c627e335 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -45,7 +45,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r ## Public interfaces and data decisions -- Auto-ban is enabled by default through a bounded Security setting. +- Auto-ban is enabled for completed installations by setup seeding the bounded Security setting to `true`. The runtime/default-provider fallback for unreadable or unavailable config storage is `false`, so active-ban evaluation and enforcement fail open during setup or database/config outages. - Newly decided ban owner alerts are enabled by default through a bounded Security setting. Alerts use hidden warning delivery and link directly to the active-ban list so Owners can review current state before drilling into details. - Primary source scoring is by Visitor ID. Stable client IP evidence is evaluated separately to reduce header/cookie mutation bypasses, but IP-only thresholds use a fixed multiplier above the Visitor threshold so legitimate visitors behind NAT or untrusted proxies are less likely to be blocked. User accounts and API keys are context for trusted-user bypass decisions, not auto-ban subjects. - The initial scoring window is one hour. @@ -62,13 +62,13 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Score thresholds and suspicious-action weights must be floored so at least one action always gets through and a ban cannot be created before the second qualifying signal for that subject type. - The first implementation uses stable code defaults in a score catalogue, with settings only for enablement, trusted-user minimum access level, and score threshold. Per-signal weight tuning may become configurable later only at the catalogue boundary with tests and policy updates. - The score is global per subject type/key, not separated into multiple buckets. Signal reasons decide weight; bucket family is diagnostic context only. -- Evaluated Security signals are limited to source-risk signals. Routine access logs, ordinary successful requests, expected validation failures, and login-required `401` responses do not contribute. Normal `404`, `403`, and `429` responses can still be weak signals because repeated misses, denials, or limiter collisions in a short window are source risk. +- Evaluated Security signals are limited to source-risk signals. Routine access logs, ordinary successful requests, expected validation failures, login-required `401` responses, and shared ignorable static/tooling/well-known paths do not contribute by status alone. Normal application `404`, `403`, and `429` responses can still be weak signals because repeated misses, denials, or limiter collisions in a short window are source risk. - Honeypot/probe signals may carry high scores because they represent high-confidence scanner behavior. Ordinary error-page and rate-limit hits must stay low enough that a single legitimate mistake is harmless while repeated hits can still cross the threshold. - Trusted registered users are never auto-banned. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, the required setting also protects Owners from self-lockout through auto-ban. Valid API keys owned by trusted users inherit this bypass because the trusted user context has been resolved before active ban enforcement. -- The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The normal login submission rendered from that recovery page must also be able to reach authentication so a trusted Owner context can be established. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. +- The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The normal login submission rendered from that recovery page must also be able to reach authentication so a trusted Owner context can be established, but only when it carries the explicit recovery marker rendered with that form. Ordinary `POST /user/login` submissions remain subject to active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. - Ban decisions follow the Security policy enforcement order so trusted-user context, trusted-user API-key context, active Admin/Owner session context, and recovery-login rendering are resolved before Visitor/IP bans can deny access, while active bans still run before error pages or rate-limit responses can be produced. - `/api/live/**` stays outside ordinary rate-limit rejection, but it is not an active auto-ban bypass. Once a Visitor/IP source is actively banned, live JSON endpoints must receive the same bare `403` enforcement unless the trusted-user or recovery-login policy applies. -- Config keys must be registered through the settings/default provider so setup, missing database, or unavailable database states read safe defaults without touching Doctrine/DBAL. When the database is unavailable, signal persistence and score evaluation cannot happen, so auto-ban degrades fail-open. +- Config keys must be registered through the settings/default provider and setup seeder so setup, missing database, or unavailable database states read safe defaults without touching Doctrine/DBAL. The auto-ban enabled runtime fallback is disabled, while setup writes the completed-installation value as enabled. When the database is unavailable, signal persistence, score evaluation, Admin list/reset, and enforcement degrade fail-open. - Active temporary ban responses use the shared `HttpErrorRenderer` forced bare response path: `403 Forbidden`, `Retry-After` when TTL is known, `Cache-Control: no-store`, safe Request ID/reference, and a generic message. They must not expose score values, rule names, subject keys, Visitor IDs, IP buckets, raw IP data, paths, headers, signal internals, or ban history, and must not record additional passive Security signals for the auto-ban `403`. - Cache-flock state is the active enforcement state holder. Explainability and escalation come from retained `security_signal_event` records, including ban-trigger and reset records, not from a separate durable ban table. - The active-ban list is backed by the active ban store's cache index, while detail/reason evidence is backed by retained Security signals. If the cache index cannot be updated atomically with newly created active state, the new active state must be rolled back so enforcement does not create an unreviewable ban. If the cache index is unavailable or inconsistent, enforcement must fail open and the Admin UI should show a safe degraded-state diagnostic rather than inferring active bans from stale historical signals alone. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 9729bfec..fc793c12 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -166,7 +166,7 @@ The codebase and other feature drafts expose several security-relevant surfaces ## 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. +- Auto-ban is enabled by setup for completed installations and can be disabled through Security policy/settings once the auto-ban branch introduces bounded configuration. Runtime/default-provider fallback must be disabled when config storage is unavailable so cached active bans are not enforced during setup or database/config outages. - Auto-ban evaluates retained Security signals over a one-hour scoring window. The score is global per subject type/key, not split into separate buckets. - Source-risk Security signals contribute to the score: repeated error responses, high-signal probes, failed auth/rate activity, invalid API/CORS probing, copied-session or copied-visitor-cookie risk, setup-apply abuse, upload/archive validation abuse, diagnostic/export probing, and similarly explicit abuse signals. Routine access, ordinary successful requests, expected validation failures, and login-required `401` responses do not contribute by status alone. - `400`, `403`, `404`, and `429` responses emit low-weight source-risk signals by default. Probe responses already return `400`, so probe and response-status evidence for the same request must be correlated and not double-counted as independent actions. @@ -192,7 +192,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - Visitor IDs and IP buckets that resolve to a trusted registered user session or trusted-user-owned API key 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 the recovery login render path `GET /user/login?bypass=1`, resolved through the shared `RequestPathResolver`, so the normal login form remains reachable 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. +- Provide the recovery login render path `GET /user/login?bypass=1`, resolved through the shared `RequestPathResolver`, so the normal login form remains reachable 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. Login submissions bypass an active source ban only when they carry the explicit recovery marker rendered by that recovery form; unsafe login submissions with only `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. ## Captcha Defaults @@ -240,7 +240,7 @@ These are first soft decisions for which values should stay fixed, become protec | 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 | Owner-gated Security setting default `on`, seeded through Security settings defaults | Yes, bounded | Disabling requires diagnostics; cannot disable trusted-user/Owner recovery, audit, or passive signal recording by accident | +| Auto-ban enabled flag | Owner-gated Security setting seeded `on` during setup, runtime fallback `off` when config storage is unavailable | Yes, bounded | Disabling requires diagnostics; cannot disable trusted-user/Owner recovery, audit, or passive signal recording by accident | | Auto-ban trusted-user minimum level | Owner-gated required Security setting default `6`/`MANAGER` | Yes, bounded | Cannot be empty; Owners are level `9` and remain protected from auto-ban self-lockout | | Auto-ban score threshold | Owner-gated required Security setting default `100` plus score-catalogue weights | Yes, bounded | Threshold changes affect only new ban decisions; active bans are not auto-lifted; floors must allow at least one action and ban no earlier than the second qualifying signal | | Auto-ban TTLs, scoring window, IP multiplier, and escalation | Code/config defaults in an auto-ban score/policy catalogue | Possibly later at catalogue boundary | One-hour score window; TTL escalation `1h`, `3h`, `24h`, `7d`; IP threshold defaults to Visitor threshold `x2`; no permanent bans; active state uses cache-flock TTL | diff --git a/dev/manual/security-guard-snippets.md b/dev/manual/security-guard-snippets.md index 826ec772..8becda0d 100644 --- a/dev/manual/security-guard-snippets.md +++ b/dev/manual/security-guard-snippets.md @@ -63,10 +63,12 @@ Auto-ban enforcement is temporary, source-subject based, and fail-open: - Score aggregation runs only after a scoreable `security_signal_event` write. Ordinary requests perform only the active cache-state check. - Active ban state lives in cache-backed TTL entries with a cache-backed Admin index. Retained Security signals explain trigger and reset history; there is no durable ban table. - Visitor ID is the primary source subject. IP bucket/HMAC is evaluated separately with a laxer threshold multiplier. -- Trusted registered users at or above the configured trusted level, trusted-user-owned API keys, the recovery login render path, and the login submission rendered from that recovery path must bypass active Visitor/IP bans so trusted Owners can recover. +- Trusted registered users at or above the configured trusted level, trusted-user-owned API keys, the recovery login render path, and login submissions carrying the recovery marker rendered from that recovery path must bypass active Visitor/IP bans so trusted Owners can recover. - Owner review surfaces for active bans use the non-configurable `admin.settings.security` ACL gate. The browser list/detail views and `/api/v1/admin/security/auto-bans` endpoints must reject delegated non-Owner admins, and the API endpoint metadata must advertise the same Owner minimum access level. - Newly decided ban alerts are configurable and enabled by default. When enabled, active Owner accounts receive a hidden warning with an action link to the active-ban list. - Disabling auto-ban stops score evaluation and active-ban enforcement immediately. Existing TTL cache entries may remain until they expire, but they must not block requests while the feature is disabled. +- If config storage is unavailable, auto-ban enablement falls back to disabled even when cached active-ban TTL entries exist. Setup writes the completed-installation enabled value once the database is ready. +- Shared ignorable static/tooling/well-known paths such as generated assets, profiler/toolbar, favicon/touch icons, robots/sitemap, and selected `.well-known` discovery files must not create passive error-status Security signals. - The Symfony `test` environment disables kernel-triggered auto-ban evaluation and enforcement unless a request explicitly opts in with `X-Auto-Ban-Testing: 1`, so broad controller suites that intentionally render many error responses do not poison shared test cache state. - Active ban responses use the forced bare `403` path with `Retry-After`, `no-store`, a generic message, and a safe Request ID only. Auto-ban enforcement responses must not create additional passive Security signals. - Manual reset clears active cache state, records a reset Security signal, and returns a success/error alert for the release workflow. Reset success requires both reset-signal persistence and active cache-state release. Score and escalation queries ignore earlier evidence for the same subject after that reset. From 2ec5daa87f22231b353c34956cd1fdc4ca60cd6f Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 15:56:13 +0200 Subject: [PATCH 23/55] Enforce auto-bans over probe responses --- .../AutoBan/AutoBanRequestSubscriber.php | 2 +- .../AutoBan/AutoBanRequestSubscriberTest.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index c2a03a22..08599180 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -60,7 +60,7 @@ public static function getSubscribedEvents(): array public function onKernelRequest(RequestEvent $event): void { - if (!$event->isMainRequest() || $event->hasResponse() || !$this->enabledForRequest($event->getRequest()) || !$this->policy->enabled()) { + if (!$event->isMainRequest() || !$this->enabledForRequest($event->getRequest()) || !$this->policy->enabled()) { return; } diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index dec78199..246d0bac 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -66,6 +66,24 @@ public function testActiveVisitorBanReturnsBareForbiddenBeforeApplicationHandlin self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); } + public function testActiveVisitorBanOverridesEarlierProbeResponseAndSkipsPassiveSignals(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/.env', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + $event->setResponse(new Response('', 400)); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertSame('3600', $event->getResponse()?->headers->get('Retry-After')); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + public function testRecoveryLoginBypassIsReachableDespiteActiveBan(): void { $clock = new MockClock('2026-06-18 12:00:00'); From bb89778881396275978b8089b6706a235eaec7b4 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 15:58:03 +0200 Subject: [PATCH 24/55] Harden auto-ban inspection against malformed fields --- src/Security/Abuse/AbuseSubjectResolver.php | 22 +++++++++-- .../Abuse/RequestIntentClassifier.php | 18 ++++++++- .../AutoBan/AutoBanRequestSubscriber.php | 2 +- .../AutoBan/AutoBanRequestSubscriberTest.php | 37 +++++++++++++++++++ 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/Security/Abuse/AbuseSubjectResolver.php b/src/Security/Abuse/AbuseSubjectResolver.php index ede611c4..3f79665b 100644 --- a/src/Security/Abuse/AbuseSubjectResolver.php +++ b/src/Security/Abuse/AbuseSubjectResolver.php @@ -121,7 +121,7 @@ private function submittedSchedulerCredential(Request $request): ?AbuseSubject return $this->schedulerCredentialSubject(trim($matches[1])); } - $auth = $request->query->get('auth'); + $auth = $this->scalarQueryValue($request, 'auth'); return is_string($auth) ? $this->schedulerCredentialSubject(trim($auth)) : null; } @@ -154,11 +154,11 @@ private function submittedAccount(Request $request): ?AbuseSubject } if ($this->matchesExactSegments($segments, 'user', 'login')) { - return $this->submittedAccountSubject('login', $request->request->get('username')); + return $this->submittedAccountSubject('login', $this->scalarRequestValue($request, 'username')); } if ($this->matchesExactSegments($segments, 'user', 'register')) { - return $this->submittedAccountSubject('registration_email', $request->request->get('email'), email: true); + return $this->submittedAccountSubject('registration_email', $this->scalarRequestValue($request, 'email'), email: true); } if ($this->matchesSegments($segments, 'user', 'invitation') && null !== ($submittedToken = $this->tokenSegment($segments, 2))) { @@ -166,7 +166,7 @@ private function submittedAccount(Request $request): ?AbuseSubject } if ($this->matchesExactSegments($segments, 'user', 'reset-password')) { - return $this->submittedAccountSubject('password_reset_email', $request->request->get('email'), email: true); + return $this->submittedAccountSubject('password_reset_email', $this->scalarRequestValue($request, 'email'), email: true); } if ($this->matchesSegments($segments, 'user', 'reset-password') && null !== ($submittedToken = $this->tokenSegment($segments, 2))) { @@ -239,6 +239,20 @@ private function submittedAccountSubject(string $scope, mixed $value, bool $emai ]); } + private function scalarRequestValue(Request $request, string $name): mixed + { + $value = $request->request->all()[$name] ?? null; + + return is_scalar($value) ? $value : null; + } + + private function scalarQueryValue(Request $request, string $name): mixed + { + $value = $request->query->all()[$name] ?? null; + + return is_scalar($value) ? $value : 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/RequestIntentClassifier.php b/src/Security/Abuse/RequestIntentClassifier.php index c2be8c5e..0cc79718 100644 --- a/src/Security/Abuse/RequestIntentClassifier.php +++ b/src/Security/Abuse/RequestIntentClassifier.php @@ -142,7 +142,7 @@ private function recoveryLogin(Request $request, string $method, array $segments return 'GET' === $method && $this->loginSegments($segments) && $this->routeIs($route, 'user_login', 'n/a') - && '1' === (string) $request->query->get('bypass', ''); + && '1' === $this->scalarQueryValue($request, 'bypass', ''); } /** @@ -156,7 +156,7 @@ private function loginSegments(array $segments): bool private function setupApply(Request $request, array $segments): bool { return $this->matchesExactSegments($segments, 'setup', 'review') - && 'apply' === (string) $request->request->get('_setup_action', ''); + && 'apply' === $this->scalarRequestValue($request, '_setup_action', ''); } private function schedulerTrigger(Request $request): bool @@ -284,4 +284,18 @@ private function apiAdminSegments(array $segments): array : $segments; } + private function scalarRequestValue(Request $request, string $name, string $default = ''): string + { + $value = $request->request->all()[$name] ?? $default; + + return is_scalar($value) ? (string) $value : $default; + } + + private function scalarQueryValue(Request $request, string $name, string $default = ''): string + { + $value = $request->query->all()[$name] ?? $default; + + return is_scalar($value) ? (string) $value : $default; + } + } diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index 08599180..b3042c24 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -138,7 +138,7 @@ private function recoveryRequest(Request $request, AbuseRequestProfile $profile) private function validRecoveryLoginToken(Request $request): bool { - $token = $request->request->get(self::RECOVERY_LOGIN_TOKEN_FIELD); + $token = $request->request->all()[self::RECOVERY_LOGIN_TOKEN_FIELD] ?? null; return is_string($token) && '' !== $token diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index 246d0bac..5fd3850a 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -136,6 +136,43 @@ public function testOrdinaryLoginSubmissionsDoNotBypassActiveBan(): void self::assertSame(403, $event->getResponse()?->getStatusCode()); } + public function testMalformedLoginFieldsDoNotBypassActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/user/login', 'POST', [ + 'username' => ['owner'], + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => ['invalid'], + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testMalformedRecoveryQueryDoesNotBypassActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/user/login?bypass[]=1', server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + public function testLiveEndpointsDoNotBypassActiveBans(): void { $clock = new MockClock('2026-06-18 12:00:00'); From 55522a244db93d5d5a5a647bacf189a8f04a0cc1 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 15:59:28 +0200 Subject: [PATCH 25/55] Respect auto-ban responses during session binding --- .../SessionVisitorBindingSubscriber.php | 7 +++- .../SessionVisitorBindingSubscriberTest.php | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/Security/SessionVisitorBindingSubscriber.php b/src/Security/SessionVisitorBindingSubscriber.php index 8ced1338..123253aa 100644 --- a/src/Security/SessionVisitorBindingSubscriber.php +++ b/src/Security/SessionVisitorBindingSubscriber.php @@ -14,6 +14,7 @@ use App\Security\Abuse\AbuseSubjectType; use App\Security\Abuse\SecuritySignalRecorder; use App\Security\AutoBan\AutoBanPolicy; +use App\Security\AutoBan\AutoBanRequestSubscriber; use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Clock\NativeClock; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -70,7 +71,7 @@ public function onLoginSuccess(LoginSuccessEvent $event): void public function onKernelRequest(RequestEvent $event): void { - if (!$event->isMainRequest()) { + if (!$event->isMainRequest() || $event->hasResponse()) { return; } @@ -81,6 +82,10 @@ public function onKernelRequest(RequestEvent $event): void } $request = $event->getRequest(); + if ($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)) { + return; + } + $session = $this->session($request); if (null === $session) { diff --git a/tests/Security/SessionVisitorBindingSubscriberTest.php b/tests/Security/SessionVisitorBindingSubscriberTest.php index fd9087ae..fba12de2 100644 --- a/tests/Security/SessionVisitorBindingSubscriberTest.php +++ b/tests/Security/SessionVisitorBindingSubscriberTest.php @@ -15,6 +15,7 @@ use App\Security\Abuse\ActionCostCatalogue; use App\Security\Abuse\RequestIntentClassifier; use App\Security\Abuse\SecuritySignalRecorder; +use App\Security\AutoBan\AutoBanRequestSubscriber; use App\Security\SessionVisitorBindingSubscriber; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; @@ -138,6 +139,43 @@ public function testItRecordsSecuritySignalWhenTheBoundVisitorChanges(): void self::assertNotSame('', $context['ip_bucket']); } + public function testItDoesNotOverrideAutoBanResponsesOrRecordSignals(): void + { + $tokenStorage = new TokenStorage(); + $user = $this->user(); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $auditLogger = new RecordingSessionAuditLogger(); + $generator = new VisitorIdGenerator('test-secret'); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.42']); + $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + $session = new Session(new MockArraySessionStorage()); + $session->set(SessionVisitorBindingSubscriber::SESSION_VISITOR_ID, 'previousVisitorId1234'); + $request->setSession($session); + $connection = $this->signalConnection(); + $event = new RequestEvent(new SessionBindingTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + $event->setResponse(new Response('blocked', Response::HTTP_FORBIDDEN)); + + (new SessionVisitorBindingSubscriber( + $tokenStorage, + $generator, + $auditLogger, + new AbuseRequestInspector( + new AbuseSubjectResolver($generator, $tokenStorage, 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + new AccessRequestMetadata(), + ))->onKernelRequest($event); + + self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode()); + self::assertSame('blocked', $event->getResponse()?->getContent()); + self::assertNotNull($tokenStorage->getToken()); + self::assertSame('previousVisitorId1234', $session->get(SessionVisitorBindingSubscriber::SESSION_VISITOR_ID)); + self::assertSame([], $auditLogger->records); + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + public function testItKeepsSessionsWhenTheBoundVisitorMatches(): void { $tokenStorage = new TokenStorage(); From 5d246333f5d03f0450dea86f165188a05dbde6c3 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 16:00:31 +0200 Subject: [PATCH 26/55] Use occurrence ids for auto-ban owner alerts --- .../AutoBan/AutoBanOwnerAlertNotifier.php | 4 +++- .../AutoBan/AutoBanOwnerAlertNotifierTest.php | 24 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/Security/AutoBan/AutoBanOwnerAlertNotifier.php b/src/Security/AutoBan/AutoBanOwnerAlertNotifier.php index a2883f4f..9a81bfe5 100644 --- a/src/Security/AutoBan/AutoBanOwnerAlertNotifier.php +++ b/src/Security/AutoBan/AutoBanOwnerAlertNotifier.php @@ -4,6 +4,7 @@ namespace App\Security\AutoBan; +use App\Core\Id\UuidFactory; use App\Core\Message\Message; use App\Core\Message\MessageReporterInterface; use App\Security\UserAccountStatus; @@ -25,6 +26,7 @@ public function __construct( private Connection $connection, private UiAlertDispatcherInterface $alerts, private ?MessageReporterInterface $messageReporter = null, + private UuidFactory $uuidFactory = new UuidFactory(), ) { } @@ -40,7 +42,7 @@ public function notifyBanTriggered(ActiveAutoBan $ban): void ]); $presentation = UiAlertPresentation::hidden(actions: [ UiAlertAction::link('Review', '/admin/security/auto-bans'), - ], id: 'auto-ban-triggered-'.$ban->key()); + ], id: 'auto-ban-triggered-'.$ban->key().'-'.$this->uuidFactory->generate()); foreach ($this->ownerUids() as $uid) { $this->alerts->addAlertToUser($uid, $alert, UiAlertDelivery::Queue, $presentation); diff --git a/tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php b/tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php index 110f9630..c0761753 100644 --- a/tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php +++ b/tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php @@ -53,12 +53,34 @@ public function testItQueuesHiddenOwnerAlertsForNewBans(): void self::assertInstanceOf(UiAlertTranslation::class, $alerts->userAlerts[0]['alert']); self::assertSame('admin.auto_bans.alerts.triggered', $alerts->userAlerts[0]['alert']->translationKey()); self::assertSame('hidden', $alerts->userAlerts[0]['presentation']?->mode()); - self::assertSame('auto-ban-triggered-'.$ban->key(), $alerts->userAlerts[0]['presentation']?->id()); + self::assertStringStartsWith('auto-ban-triggered-'.$ban->key().'-', (string) $alerts->userAlerts[0]['presentation']?->id()); self::assertSame([ ['label' => 'Review', 'href' => '/admin/security/auto-bans'], ], $alerts->userAlerts[0]['presentation']?->actions()); } + public function testRepeatBanAlertsUseDifferentPresentationIds(): void + { + $connection = $this->connection(); + $connection->insert('user_account', [ + 'uid' => 'owner-uid', + 'role' => UserRole::Owner->value, + 'status' => UserAccountStatus::Active->value, + ]); + $alerts = new RecordingAutoBanAlertDispatcher(); + $notifier = new AutoBanOwnerAlertNotifier(new AutoBanPolicy(new Config($connection)), $connection, $alerts); + $ban = $this->ban(); + + $notifier->notifyBanTriggered($ban); + $notifier->notifyBanTriggered($ban); + + self::assertCount(2, $alerts->userAlerts); + self::assertNotSame( + $alerts->userAlerts[0]['presentation']?->id(), + $alerts->userAlerts[1]['presentation']?->id(), + ); + } + public function testItSkipsOwnerAlertsWhenDeliveryIsDisabled(): void { $connection = $this->connection(); From 4e56f1b876bb583e63ef51f08f2e17bf6794b655 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 16:01:00 +0200 Subject: [PATCH 27/55] Document third auto-ban review fixes --- dev/WORKLOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 3801d04b..8dd050f9 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -100,6 +100,7 @@ - Verification: `php -l` on changed PHP entry points passed; `php bin/console lint:container` passed; focused AutoBan/API/settings/message PHPUnit groups passed; full `php bin/phpunit` passed with 1631 tests and 10708 assertions; `bin/lint --diff` plus explicit lint for new auto-ban files passed. - Addressed first Cloud Review round with separate reviewable commits: recovery login submissions can authenticate despite source bans; active-ban index updates are serialized and roll back unindexed active state; failed cache deletes make reset fail; reset success requires reset-signal persistence; concurrent evaluators emit trigger signals/Owner alerts only for newly created bans; detail views are newest-first while retaining history; auto-ban `403` responses do not create passive signals; and auto-ban API endpoints now advertise Owner-level access before the handler ACL gate. - Addressed second Cloud Review round with separate reviewable commits: index-write rollback now verifies active-state removal and falls back to an expired payload when cache deletion fails; shared ignorable static/tooling/well-known path matching prevents routine missing favicon/robots/touch-icon/discovery requests from creating passive `404` Security signals; login ban bypass now requires the CSRF-backed recovery marker rendered by `GET /user/login?bypass=1`; and auto-ban enablement fails open when config storage is unavailable while setup still seeds completed installations as enabled. +- Addressed third Cloud Review round with separate reviewable commits: active auto-bans override earlier probe responses and set the passive-signal skip marker; malformed scalar request/query fields in the auto-ban inspection path can no longer make active-ban enforcement fail open; session/visitor binding now respects existing auto-ban responses and skip markers; and repeated Owner alerts for the same banned subject use occurrence-specific presentation IDs. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). From 347bc061c663b049eea2fb9bc653bef3a6577e6a Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 16:20:43 +0200 Subject: [PATCH 28/55] Add suspicious payload security signals --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + .../security-hardening/abuse-foundation.md | 3 +- dev/draft/security-hardening/auto-ban.md | 10 +- .../security-hardening/policy-defaults.md | 6 +- .../SuspiciousPayloadSignalSubscriber.php | 125 ++++++++++++++ .../Abuse/SuspiciousRequestPayloadMatcher.php | 162 ++++++++++++++++++ .../AutoBan/AutoBanScoreCatalogue.php | 6 + tests/Core/Log/AccessLogSubscriberTest.php | 27 +++ .../SuspiciousPayloadSignalSubscriberTest.php | 153 +++++++++++++++++ .../SuspiciousRequestPayloadMatcherTest.php | 44 +++++ 11 files changed, 530 insertions(+), 9 deletions(-) create mode 100644 src/Security/Abuse/SuspiciousPayloadSignalSubscriber.php create mode 100644 src/Security/Abuse/SuspiciousRequestPayloadMatcher.php create mode 100644 tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php create mode 100644 tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index ce2ccf32..cbf0b04d 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -200,7 +200,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 high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | -| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\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 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, records source-subject Visitor/IP signals for high-signal probes and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | +| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted GET/POST attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted code-bearing forms, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS`, 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/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets trusted users plus recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, and records Owner reset signals that clear active cache state and invalidate earlier scoring evidence. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 8dd050f9..801acefc 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -101,6 +101,7 @@ - Addressed first Cloud Review round with separate reviewable commits: recovery login submissions can authenticate despite source bans; active-ban index updates are serialized and roll back unindexed active state; failed cache deletes make reset fail; reset success requires reset-signal persistence; concurrent evaluators emit trigger signals/Owner alerts only for newly created bans; detail views are newest-first while retaining history; auto-ban `403` responses do not create passive signals; and auto-ban API endpoints now advertise Owner-level access before the handler ACL gate. - Addressed second Cloud Review round with separate reviewable commits: index-write rollback now verifies active-state removal and falls back to an expired payload when cache deletion fails; shared ignorable static/tooling/well-known path matching prevents routine missing favicon/robots/touch-icon/discovery requests from creating passive `404` Security signals; login ban bypass now requires the CSRF-backed recovery marker rendered by `GET /user/login?bypass=1`; and auto-ban enablement fails open when config storage is unavailable while setup still seeds completed installations as enabled. - Addressed third Cloud Review round with separate reviewable commits: active auto-bans override earlier probe responses and set the passive-signal skip marker; malformed scalar request/query fields in the auto-ban inspection path can no longer make active-ban enforcement fail open; session/visitor binding now respects existing auto-ban responses and skip markers; and repeated Owner alerts for the same banned subject use occurrence-specific presentation IDs. +- Added scoreable suspicious payload signals for obvious public/untrusted GET/POST abuse patterns and malformed scalar-only security parameters. The payload matcher records only safe pattern classes and bounded parameter metadata, never raw submitted values, and skips Admin/Editor/Setup/trusted contexts so legitimate code-bearing fields such as schema custom Twig are not treated as probes; active auto-ban `403` requests still suppress new Security signals but remain access-log entries for Request-ID/Visitor/IP audit correlation. ### 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 bb019cb9..3166f88d 100644 --- a/dev/draft/security-hardening/abuse-foundation.md +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -63,7 +63,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Request classification is passive and deterministic. `/api/live/**`, safe browser prefetch, and anonymous CORS preflight receive no ordinary enforcement cost in this branch; credentialed API preflights are classified by their requested method, while suspicious probes, exact setup review apply, and mutating admin/API workflows receive higher symbolic costs for later limiter branches. - Public-facing unsafe requests that are not classified as a more specific workflow, for example future contact alternatives, comments, forum posts, package-provided public forms, or other user-submitted public content actions, must fall back to the dedicated `website_form`/`FormSubmit` bucket instead of the cheap navigation bucket. - Contact forms, captcha challenges, and package-owned public workflows must not be inferred from invented or path-only slugs such as `/contact` or `/captcha/refresh`. Public content may legitimately use those slugs; later feature branches must opt into special intents through real route names, explicit workflow metadata, or provider-owned `/api/live/**` endpoints. -- `PassiveAbuseSignalSubscriber` records only clear passive signals in this branch, starting with high-signal probe paths and unsafe prefetch attempts. It writes Visitor-ID and IP-bucket HMAC context where available, never raw IP or forwarding-header values, and does not alter the response. +- `PassiveAbuseSignalSubscriber` records only clear passive signals in this branch, starting with high-signal probe paths and unsafe prefetch attempts. `SuspiciousPayloadSignalSubscriber` adds the same source-subject signal shape for obvious GET/POST abuse payloads such as scalar-only security parameter malforming, clear SQL-injection probes, path traversal, sensitive file probes, JNDI lookups, and script-tag probes. Payload scanning is limited to public/untrusted request surfaces and skips Admin, Editor, Setup, and trusted-user contexts because legitimate code/template/content fields may intentionally contain strings such as `', + ], server: [ + 'REMOTE_ADDR' => '203.0.113.10', + ]); + + $this->subscriber($connection, $visitorIds, new AccessRequestMetadata())->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + + private function subscriber(Connection $connection, VisitorIdGenerator $visitorIds, AccessRequestMetadata $metadata): SuspiciousPayloadSignalSubscriber + { + return new SuspiciousPayloadSignalSubscriber( + new SuspiciousRequestPayloadMatcher(), + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + $metadata, + ); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + + return $connection; + } +} + +final class SuspiciousPayloadSignalTestKernel implements HttpKernelInterface +{ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } +} diff --git a/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php b/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php new file mode 100644 index 00000000..53f4f339 --- /dev/null +++ b/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php @@ -0,0 +1,44 @@ +match(Request::create('/search', 'GET', [ + 'q' => "x' UNION SELECT password FROM users --", + 'file' => '../../etc/passwd', + ])); + + self::assertIsArray($match); + self::assertContains('sql_union_select', $match['signatures']); + self::assertContains('sensitive_file_probe', $match['signatures']); + self::assertSame('q', $match['parameters'][0]['name']); + self::assertStringNotContainsString('UNION SELECT', json_encode($match, JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString('/etc/passwd', json_encode($match, JSON_THROW_ON_ERROR)); + } + + public function testItDetectsMalformedSecurityParametersButAllowsOrdinaryArrays(): void + { + $matcher = new SuspiciousRequestPayloadMatcher(); + + $malformed = $matcher->match(Request::create('/user/login', 'POST', [ + 'username' => ['owner'], + ])); + $ordinary = $matcher->match(Request::create('/search', 'GET', [ + 'tags' => ['one', 'two'], + ])); + + self::assertIsArray($malformed); + self::assertContains('malformed_parameter', $malformed['signatures']); + self::assertSame('username', $malformed['parameters'][0]['name']); + self::assertNull($ordinary); + } +} From 04e9be8e1950110a82905adc17cba8002a0da14e Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 16:35:04 +0200 Subject: [PATCH 29/55] Show trigger geo context on auto-ban detail --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + dev/draft/security-hardening/auto-ban.md | 2 + .../security-hardening/policy-defaults.md | 1 + .../AutoBan/Api/AutoBanApiHandler.php | 21 ++++--- src/Security/AutoBan/AutoBanAdminBrowser.php | 53 +++++++++++++++- .../admin/security/auto-ban-detail.html.twig | 3 + .../AutoBan/AutoBanAdminBrowserTest.php | 62 +++++++++++++++++++ translations/languages/de/admin.yaml | 2 + translations/languages/en/admin.yaml | 2 + 10 files changed, 137 insertions(+), 12 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index cbf0b04d..5b8d4908 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -202,7 +202,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, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted GET/POST attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted code-bearing forms, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS`, 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/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets trusted users plus recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, and records Owner reset signals that clear active cache state and invalidate earlier scoring evidence. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets trusted users plus recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and records Owner reset signals that clear active cache state and invalidate earlier scoring evidence. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 801acefc..aa00c04a 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -102,6 +102,7 @@ - Addressed second Cloud Review round with separate reviewable commits: index-write rollback now verifies active-state removal and falls back to an expired payload when cache deletion fails; shared ignorable static/tooling/well-known path matching prevents routine missing favicon/robots/touch-icon/discovery requests from creating passive `404` Security signals; login ban bypass now requires the CSRF-backed recovery marker rendered by `GET /user/login?bypass=1`; and auto-ban enablement fails open when config storage is unavailable while setup still seeds completed installations as enabled. - Addressed third Cloud Review round with separate reviewable commits: active auto-bans override earlier probe responses and set the passive-signal skip marker; malformed scalar request/query fields in the auto-ban inspection path can no longer make active-ban enforcement fail open; session/visitor binding now respects existing auto-ban responses and skip markers; and repeated Owner alerts for the same banned subject use occurrence-specific presentation IDs. - Added scoreable suspicious payload signals for obvious public/untrusted GET/POST abuse patterns and malformed scalar-only security parameters. The payload matcher records only safe pattern classes and bounded parameter metadata, never raw submitted values, and skips Admin/Editor/Setup/trusted contexts so legitimate code-bearing fields such as schema custom Twig are not treated as probes; active auto-ban `403` requests still suppress new Security signals but remain access-log entries for Request-ID/Visitor/IP audit correlation. +- Enriched active auto-ban detail with country/continent from the latest retained ban-trigger signal's Request ID by reading the matching access-log projection. Security signals still do not duplicate raw IP or per-signal GeoIP values, and missing/expired access-log context falls back to `n/a`. ### 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 3ed34faa..025ca4d8 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -56,6 +56,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - When one incoming Security signal makes both Visitor and IP scores eligible, create at most one new active ban for that signal and prefer the Visitor ban. Create an IP ban only when the IP score crosses the laxer threshold and no Visitor ban is created for the same evaluation. Trigger signals and Owner alerts are emitted only when the active ban state is newly created, not when a concurrent evaluator observes an already active ban. This preserves the IP defense against cookie/header mutation without unnecessarily broadening NAT impact. - Scoreable request signals should be recorded per evaluated source subject, not only as a primary subject with the IP bucket hidden in JSON context. The same request may therefore create paired Visitor/IP signal rows with a shared request ID or correlation context, while Admin detail views deduplicate them for human review. - Score aggregation is write-triggered, not request-triggered. After a scoreable `security_signal_event` insert succeeds, the same DB connection may query retained rows for the affected Visitor/IP subjects using the existing `subject_type`, `subject_identifier`, and `occurred_at` index, apply the latest reset cutoff and one-hour window, and decide whether to write a ban-trigger signal plus cache-flock state. Requests with no new scoreable signal only perform the cheap active-ban cache check. +- Auto-ban detail may show coarse GeoIP audit context for the banned subject by reading the latest retained ban-trigger signal's Request ID and joining that Request ID to the access-log projection. Security signals must not duplicate raw IP or per-signal GeoIP values; the detail view should expose only the trigger request's country and continent, with `n/a` fallback when access-log context is missing or expired. - Security-signal retention resets escalation naturally. Once prior ban-trigger signals expire or are reset, later bans start from the lower escalation tier again. - Manual reset takes effect immediately by deleting/clearing active cache-flock ban state and recording a reset signal. Score and escalation queries ignore earlier signals at or before the latest reset signal for the same subject type/key. - Threshold changes apply immediately for new decisions only. Existing active bans are not lifted automatically. If a subject is now above a lowered threshold but is not currently banned, the next qualifying Security signal triggers evaluation and may create the ban. This policy is intentional and should be preserved in reviews. @@ -115,6 +116,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test bare browser `403` response shape, `Retry-After`, request ID, `no-store`, and redaction. - Test that bare auto-ban `403` responses remain access-log entries even though they do not emit new Security signals. - Test Admin active-ban list, detail filtering, API list/detail/reset endpoints, and manual reset permissions/audit/signal creation. +- Test auto-ban detail GeoIP enrichment from the latest ban-trigger Request ID without copying GeoIP values into every Security signal. - Test that delegated non-Owner admins cannot access active-ban browser or API surfaces through the `admin.settings.security` ACL gate. - Test settings descriptors, default provider values, validation bounds, translations, and missing-database defaults. - Test migration/schema only if this branch changes existing Security signal fields; the preferred implementation should avoid new ban tables. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 9311048b..a8de5372 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. +- Auto-ban detail may use the latest ban-trigger signal's Request ID to show coarse access-log GeoIP context, limited to country and continent, for Owner audit review. Security signals must not duplicate raw IP or per-signal GeoIP data. - 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 diff --git a/src/Security/AutoBan/Api/AutoBanApiHandler.php b/src/Security/AutoBan/Api/AutoBanApiHandler.php index 369f1f50..6b8020a9 100644 --- a/src/Security/AutoBan/Api/AutoBanApiHandler.php +++ b/src/Security/AutoBan/Api/AutoBanApiHandler.php @@ -144,22 +144,23 @@ private function banResource(array $ban): array } /** - * @param array{ban: array, signals: list>} $detail + * @param array{ban: array, trigger_geo: array{request_id: string, country: string, continent: string}, signals: list>} $detail * * @return array */ private function detailResource(array $detail): array { - return [ - ...$this->banResource($detail['ban']), - 'relationships' => [ - 'signals' => array_map(static fn (array $signal): array => [ - 'type' => 'security_signal', - 'id' => (string) ($signal['uid'] ?? ''), - 'attributes' => $signal, - ], $detail['signals']), - ], + $resource = $this->banResource($detail['ban']); + $resource['attributes']['trigger_geo'] = $detail['trigger_geo']; + $resource['relationships'] = [ + 'signals' => array_map(static fn (array $signal): array => [ + 'type' => 'security_signal', + 'id' => (string) ($signal['uid'] ?? ''), + 'attributes' => $signal, + ], $detail['signals']), ]; + + return $resource; } private function notFound(Request $request, ?string $key): Response diff --git a/src/Security/AutoBan/AutoBanAdminBrowser.php b/src/Security/AutoBan/AutoBanAdminBrowser.php index 18d6601a..ccbe17d6 100644 --- a/src/Security/AutoBan/AutoBanAdminBrowser.php +++ b/src/Security/AutoBan/AutoBanAdminBrowser.php @@ -37,7 +37,7 @@ public function activeList(): array } /** - * @return array{ban: array, signals: list>}|null + * @return array{ban: array, trigger_geo: array{request_id: string, country: string, continent: string}, signals: list>}|null */ public function detail(string $key): ?array { @@ -48,6 +48,7 @@ public function detail(string $key): ?array return [ 'ban' => $ban->toArray(), + 'trigger_geo' => $this->triggerGeo($ban), 'signals' => $this->signals($ban), ]; } @@ -75,6 +76,49 @@ private function signals(ActiveAutoBan $ban): array } } + /** + * @return array{request_id: string, country: string, continent: string} + */ + private function triggerGeo(ActiveAutoBan $ban): array + { + $empty = ['request_id' => 'n/a', 'country' => 'n/a', 'continent' => 'n/a']; + + try { + $requestId = $this->connection->fetchOne( + 'SELECT request_id FROM '.self::TABLE.' WHERE subject_type = ? AND subject_identifier = ? AND reason_code = ? AND expires_at > ? ORDER BY occurred_at DESC, uid DESC LIMIT 1', + [ + $ban->subjectType(), + $ban->subjectIdentifier(), + AutoBanScoreCatalogue::SIGNAL_TRIGGERED, + $this->clock->now()->format('Y-m-d H:i:s'), + ], + ); + + if (!is_string($requestId) || '' === trim($requestId) || 'n/a' === trim($requestId)) { + return $empty; + } + + $row = $this->connection->fetchAssociative( + 'SELECT country, continent FROM access_log_entry WHERE request_id = ? ORDER BY occurred_at DESC, uid DESC LIMIT 1', + [$requestId], + ); + + if (!is_array($row)) { + return ['request_id' => $requestId, 'country' => 'n/a', 'continent' => 'n/a']; + } + + return [ + 'request_id' => $requestId, + 'country' => $this->presentGeoValue($row['country'] ?? null), + 'continent' => $this->presentGeoValue($row['continent'] ?? null), + ]; + } catch (Throwable $error) { + $this->reportStorage('trigger_geo', $error, ['active_ban_key' => $ban->key()]); + + return $empty; + } + } + /** * @param array $row * @@ -116,6 +160,13 @@ private function safeContext(mixed $context): array return is_array($decoded) ? $decoded : []; } + private function presentGeoValue(mixed $value): string + { + $value = is_scalar($value) ? trim((string) $value) : ''; + + return '' === $value ? 'n/a' : mb_substr($value, 0, 80); + } + /** * @param array $context */ diff --git a/templates/backend/admin/security/auto-ban-detail.html.twig b/templates/backend/admin/security/auto-ban-detail.html.twig index 12529337..dbef6613 100644 --- a/templates/backend/admin/security/auto-ban-detail.html.twig +++ b/templates/backend/admin/security/auto-ban-detail.html.twig @@ -15,6 +15,7 @@ } only %} {% set ban = auto_ban_detail.ban %} + {% set trigger_geo = auto_ban_detail.trigger_geo %}

{{ ban.subject_label }}

@@ -23,6 +24,8 @@ + +
{{ 'admin.auto_bans.columns.created_at'|trans }}{{ ban.created_at }}
{{ 'admin.auto_bans.columns.expires_at'|trans }}{{ ban.expires_at }}
{{ 'admin.auto_bans.columns.ttl'|trans }}{{ ban.ttl_seconds }}
{{ 'admin.auto_bans.detail.trigger_country'|trans }}{{ trigger_geo.country|default('admin.logs.empty_value'|trans) }}
{{ 'admin.auto_bans.detail.trigger_continent'|trans }}{{ trigger_geo.continent|default('admin.logs.empty_value'|trans) }}
{% if auto_ban_reset_mutable|default(false) %} diff --git a/tests/Security/AutoBan/AutoBanAdminBrowserTest.php b/tests/Security/AutoBan/AutoBanAdminBrowserTest.php index efa8c941..d83ea9b7 100644 --- a/tests/Security/AutoBan/AutoBanAdminBrowserTest.php +++ b/tests/Security/AutoBan/AutoBanAdminBrowserTest.php @@ -43,6 +43,63 @@ public function testDetailKeepsNewestSignalsVisibleBeforeRetainedHistory(): void self::assertContains('old-104', array_column($detail['signals'], 'uid')); } + public function testDetailShowsGeoFromLatestBanTriggerRequest(): void + { + $connection = $this->connection(); + $this->createAccessLogTable($connection); + $clock = new MockClock('2026-06-18 13:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::IP, 'ip-bucket-detail-geo', true); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + + $this->insertSignal($connection, $subject, 'trigger-old', '2026-06-18 12:01:00', AutoBanScoreCatalogue::SIGNAL_TRIGGERED); + $this->insertSignal($connection, $subject, 'trigger-new', '2026-06-18 12:02:00', AutoBanScoreCatalogue::SIGNAL_TRIGGERED); + $connection->insert('access_log_entry', [ + 'uid' => 'access-old', + 'occurred_at' => '2026-06-18 12:01:00', + 'request_id' => 'request-trigger-old', + 'country' => 'DE', + 'continent' => 'EU', + ]); + $connection->insert('access_log_entry', [ + 'uid' => 'access-new', + 'occurred_at' => '2026-06-18 12:02:00', + 'request_id' => 'request-trigger-new', + 'country' => 'NL', + 'continent' => 'EU', + ]); + + $detail = (new AutoBanAdminBrowser($store, $connection, clock: $clock))->detail($ban->key()); + self::assertNotNull($detail); + + self::assertSame([ + 'request_id' => 'request-trigger-new', + 'country' => 'NL', + 'continent' => 'EU', + ], $detail['trigger_geo']); + } + + public function testDetailUsesSafeGeoPlaceholdersWhenAccessLogIsUnavailable(): void + { + $connection = $this->connection(); + $clock = new MockClock('2026-06-18 13:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-detail-no-geo'); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + $this->insertSignal($connection, $subject, 'trigger-new', '2026-06-18 12:02:00', AutoBanScoreCatalogue::SIGNAL_TRIGGERED); + + $detail = (new AutoBanAdminBrowser($store, $connection, clock: $clock))->detail($ban->key()); + self::assertNotNull($detail); + + self::assertSame([ + 'request_id' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $detail['trigger_geo']); + } + private function connection(): Connection { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); @@ -51,6 +108,11 @@ private function connection(): Connection return $connection; } + private function createAccessLogTable(Connection $connection): void + { + $connection->executeStatement('CREATE TABLE access_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, request_id VARCHAR(64) NOT NULL, country VARCHAR(80) NOT NULL, continent VARCHAR(80) NOT NULL)'); + } + private function insertSignal( Connection $connection, AutoBanSubject $subject, diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index d5446444..d385549f 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -671,6 +671,8 @@ admin: title: 'Auto-Ban-Detail' signals: 'Zugehörige Security-Signale' no_signals: 'Für diesen aktiven Bann sind keine aufbewahrten Signale verfügbar.' + trigger_country: 'Auslöse-Land' + trigger_continent: 'Auslöse-Kontinent' reset: submit: 'Auto-Ban zurücksetzen' saved: 'Auto-Ban zurückgesetzt.' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index 04662947..f100b045 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -671,6 +671,8 @@ admin: title: 'Auto-ban detail' signals: 'Related security signals' no_signals: 'No retained signals are available for this active ban.' + trigger_country: 'Trigger country' + trigger_continent: 'Trigger continent' reset: submit: 'Reset auto-ban' saved: 'Auto-ban reset.' From 42e1c13305364d47780289319dbf86e9e0530d2c Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 16:41:31 +0200 Subject: [PATCH 30/55] Document geo traffic-shedding follow-up --- dev/WORKLOG.md | 1 + dev/draft/security-hardening/policy-defaults.md | 1 + 2 files changed, 2 insertions(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index aa00c04a..ca533383 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -75,6 +75,7 @@ - [ ] Audit follow-up: split the remaining large Admin ACL-adjacent controllers and API handlers along route/action boundaries when those domains are touched next; this slice already extracted the new matrix/form construction, while broader splits for `BackendController`, Admin user/ACL/package controllers, package APIs, operation/scheduler APIs, and the pre-existing large user ACL/review API handlers would be safer as a dedicated behavior-stable refactor. - [ ] Editor/Content/Config follow-up: warn non-blockingly when a proposed route or slug would match a configured suspicious probe path, so legitimate content remains possible but accidental high-signal probe namespace collisions are visible before publication. - [ ] Captcha/rate-limit follow-up: add a short-lived opaque 429 recovery context when real captcha challenges are wired, so verified provider-backed solves can reset only the whitelisted/resettable descriptor and subject scope that produced the rendered 429 without exposing bucket IDs, subject keys, IP data, or limiter internals. +- [ ] Aggregation/rate-limit follow-up: evaluate short-lived emergency country/continent traffic-shedding buckets for DDoS-like spikes. Treat this as aggregate rate limiting, not auto-ban or geo-blocking; ignore `n/a` GeoIP, keep thresholds extreme, preserve trusted-user recovery and Owner/API access, and use brief windows such as 5-15 minutes. - [ ] Audit follow-up: decide whether optional branding packages need capabilities beyond `system-template`; package CSS class namespace validation is now enforced for package-owned selectors. - [ ] Evaluate whether the documented minimum memory requirement should become 256M after PHPUnit 13.2/full-suite runs needed a higher CLI memory limit; do not fix this requirement until setup/init/lint/runtime memory behavior has been reviewed across target hosting platforms. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index a8de5372..67c406f2 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -162,6 +162,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - 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. 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. +- Future aggregation/rate-limit work may evaluate emergency country or continent traffic-shedding buckets for DDoS-like spikes. This must be a short-lived aggregate rate-limit defense, not an auto-ban subject or geo-blocking policy: unknown GeoIP (`n/a`) is ignored, thresholds must be extreme, windows should stay brief such as 5-15 minutes, trusted-user recovery and Owner/API access must remain available, and the response should be a minimal `429`/shed path rather than durable bans. - 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. From 36e6f470516aaf083f0566ea782132d2154631d2 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 17:07:58 +0200 Subject: [PATCH 31/55] Enforce auto-bans before probe and login side effects --- .../AutoBan/AutoBanRequestSubscriber.php | 131 ++++++++++++++---- .../RateLimit/RateLimitRequestSubscriber.php | 5 + .../AutoBan/AutoBanRequestSubscriberTest.php | 76 +++++++++- .../RateLimitRequestSubscriberTest.php | 26 ++++ 4 files changed, 207 insertions(+), 31 deletions(-) diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index b3042c24..08065cc0 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -8,14 +8,16 @@ use App\Core\Log\AccessRequestMetadata; use App\Core\Message\Message; use App\Core\Message\MessageReporterInterface; -use App\Core\Routing\IgnorableRequestPathMatcher; +use App\Core\Routing\RequestPathResolver; use App\Security\SecurityMessageCode; use App\Security\SecurityMessageKey; use App\Security\Abuse\AbuseRequestInspector; use App\Security\Abuse\AbuseRequestProfile; use App\Security\Abuse\AbuseSubject; +use App\Security\Abuse\AbuseSubjectResolution; use App\Security\Abuse\AbuseSubjectType; use App\Security\Abuse\RequestIntent; +use App\Security\Abuse\SuspiciousProbePathMatcher; use App\View\Http\HttpErrorRenderer; use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Clock\NativeClock; @@ -31,10 +33,12 @@ final readonly class AutoBanRequestSubscriber implements EventSubscriberInterface { public const PASSIVE_SIGNAL_SKIP_ATTRIBUTE = '_system_auto_ban_response'; + public const PROBE_RATE_LIMIT_SKIP_ATTRIBUTE = '_system_auto_ban_skip_probe_rate_limit'; public const RECOVERY_LOGIN_TOKEN_FIELD = '_auto_ban_recovery_token'; public const RECOVERY_LOGIN_TOKEN_ID = 'auto_ban_recovery_login'; - private IgnorableRequestPathMatcher $ignorablePaths; + private SuspiciousProbePathMatcher $probePaths; + private RequestPathResolver $paths; public function __construct( private AbuseRequestInspector $inspector, @@ -45,57 +49,95 @@ public function __construct( private string $environment = 'prod', private ?MessageReporterInterface $messageReporter = null, private ClockInterface $clock = new NativeClock(), - ?IgnorableRequestPathMatcher $ignorablePaths = null, + ?SuspiciousProbePathMatcher $probePaths = null, + ?RequestPathResolver $paths = null, private ?CsrfTokenManagerInterface $csrfTokens = null, ) { - $this->ignorablePaths = $ignorablePaths ?? new IgnorableRequestPathMatcher(); + $this->probePaths = $probePaths ?? new SuspiciousProbePathMatcher(); + $this->paths = $paths ?? new RequestPathResolver(); } public static function getSubscribedEvents(): array { return [ - KernelEvents::REQUEST => ['onKernelRequest', 4], + KernelEvents::REQUEST => [ + ['onKernelRequestProbeCandidate', 4097], + ['onKernelRequestLogin', 16], + ['onKernelRequest', 4], + ], ]; } - public function onKernelRequest(RequestEvent $event): void + public function onKernelRequestProbeCandidate(RequestEvent $event): void { - if (!$event->isMainRequest() || !$this->enabledForRequest($event->getRequest()) || !$this->policy->enabled()) { + $request = $event->getRequest(); + if (!$event->isMainRequest() || !$this->enabledForRequest($request) || !$this->policy->enabled() || !$this->probePaths->isProbe($request->getPathInfo())) { + return; + } + + try { + $inspection = $this->inspector->inspect($request); + if ($this->activeBanFor($inspection['subjects']) instanceof ActiveAutoBan) { + $request->attributes->set(self::PROBE_RATE_LIMIT_SKIP_ATTRIBUTE, true); + } + } catch (Throwable $error) { + $this->reportEvaluation($error, [ + 'operation' => 'probe_candidate', + 'path' => $request->getPathInfo(), + ]); + return; } + } + public function onKernelRequestLogin(RequestEvent $event): void + { $request = $event->getRequest(); - if ($this->excludedRequest($request)) { + if (!$event->isMainRequest() || $event->hasResponse() || !$this->enabledForRequest($request) || !$this->policy->enabled() || !$this->loginSubmissionCandidate($request)) { return; } try { $inspection = $this->inspector->inspect($request); - if ($this->recoveryRequest($request, $inspection['profile']) || $this->trustedContext($inspection['subjects']->subjects())) { + if ($this->recoveryRequest($request, $inspection['profile'])) { return; } - foreach ([AbuseSubjectType::Visitor, AbuseSubjectType::IpBucket] as $type) { - $subject = $inspection['subjects']->first($type); - if (!$subject instanceof AbuseSubject) { - continue; - } + $ban = $this->activeBanFor($inspection['subjects']); + if (!$ban instanceof ActiveAutoBan) { + return; + } - $autoBanSubject = AutoBanSubject::fromAbuseSubject($subject); - if (!$autoBanSubject instanceof AutoBanSubject) { - continue; - } + $event->setResponse($this->banResponse($request, $ban)); + $request->attributes->set(self::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + } catch (Throwable $error) { + $this->reportEvaluation($error, [ + 'operation' => 'login_enforcement', + 'path' => $request->getPathInfo(), + ]); - $ban = $this->store->active($autoBanSubject); - if (!$ban instanceof ActiveAutoBan) { - continue; - } + return; + } + } - $event->setResponse($this->banResponse($request, $ban)); - $request->attributes->set(self::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest() || !$this->enabledForRequest($event->getRequest()) || !$this->policy->enabled()) { + return; + } + $request = $event->getRequest(); + try { + $inspection = $this->inspector->inspect($request); + if ($this->recoveryRequest($request, $inspection['profile']) || $this->trustedContext($inspection['subjects']->subjects())) { return; } + + $ban = $this->activeBanFor($inspection['subjects']); + if ($ban instanceof ActiveAutoBan) { + $event->setResponse($this->banResponse($request, $ban)); + $request->attributes->set(self::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + } } catch (Throwable $error) { $this->reportEvaluation($error, [ 'operation' => 'request_enforcement', @@ -124,6 +166,28 @@ private function trustedContext(array $subjects): bool return false; } + private function activeBanFor(AbuseSubjectResolution $subjects): ?ActiveAutoBan + { + foreach ([AbuseSubjectType::Visitor, AbuseSubjectType::IpBucket] as $type) { + $subject = $subjects->first($type); + if (!$subject instanceof AbuseSubject) { + continue; + } + + $autoBanSubject = AutoBanSubject::fromAbuseSubject($subject); + if (!$autoBanSubject instanceof AutoBanSubject) { + continue; + } + + $ban = $this->store->active($autoBanSubject); + if ($ban instanceof ActiveAutoBan) { + return $ban; + } + } + + return null; + } + private function recoveryRequest(Request $request, AbuseRequestProfile $profile): bool { if (RequestIntent::RecoveryLogin === $profile->intent()) { @@ -146,6 +210,20 @@ private function validRecoveryLoginToken(Request $request): bool && $this->csrfTokens->isTokenValid(new CsrfToken(self::RECOVERY_LOGIN_TOKEN_ID, $token)); } + private function loginSubmissionCandidate(Request $request): bool + { + if ('POST' !== strtoupper($request->getMethod())) { + return false; + } + + $route = $request->attributes->get('_route'); + if ('user_login' === $route) { + return true; + } + + return $this->paths->matchesExact($request, 'user', 'login'); + } + private function banResponse(Request $request, ActiveAutoBan $ban): Response { $retryAfter = $ban->retryAfterSeconds($this->clock->now()); @@ -158,11 +236,6 @@ private function banResponse(Request $request, ActiveAutoBan $ban): Response ]); } - private function excludedRequest(Request $request): bool - { - return $this->ignorablePaths->matches($request->getPathInfo()); - } - private function enabledForRequest(Request $request): bool { return 'test' !== $this->environment || '1' === $request->headers->get('X-Auto-Ban-Testing'); diff --git a/src/Security/RateLimit/RateLimitRequestSubscriber.php b/src/Security/RateLimit/RateLimitRequestSubscriber.php index addf4e98..e87f8575 100644 --- a/src/Security/RateLimit/RateLimitRequestSubscriber.php +++ b/src/Security/RateLimit/RateLimitRequestSubscriber.php @@ -7,6 +7,7 @@ use App\Core\Routing\PathScopeMatcher; use App\Core\Routing\IgnorableRequestPathMatcher; use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\AutoBan\AutoBanRequestSubscriber; use App\Setup\SetupCompletionMarker; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Request; @@ -60,6 +61,10 @@ public function onKernelRequestProbe(RequestEvent $event): void return; } + if ($request->attributes->getBoolean(AutoBanRequestSubscriber::PROBE_RATE_LIMIT_SKIP_ATTRIBUTE)) { + return; + } + $this->enforcer->check($request, RateLimitEnforcementStage::SuspiciousProbe); $event->setResponse($this->responses->invalidRequest($request)); diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index 5fd3850a..f83959cc 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -84,6 +84,38 @@ public function testActiveVisitorBanOverridesEarlierProbeResponseAndSkipsPassive self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); } + public function testActiveVisitorBanMarksProbeRequestsBeforeRateLimitConsumption(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/.env', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestProbeCandidate($event); + + self::assertFalse($event->hasResponse()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PROBE_RATE_LIMIT_SKIP_ATTRIBUTE)); + } + + public function testIgnorablePathsDoNotBypassActiveBansWhenTheyReachSymfony(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/favicon.ico', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + public function testRecoveryLoginBypassIsReachableDespiteActiveBan(): void { $clock = new MockClock('2026-06-18 12:00:00'); @@ -100,6 +132,43 @@ public function testRecoveryLoginBypassIsReachableDespiteActiveBan(): void self::assertNull($event->getResponse()); } + public function testEarlyLoginGuardBlocksOrdinaryLoginSubmissionsBeforeAuthentication(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/user/login', 'POST', ['username' => 'owner'], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestLogin($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testEarlyLoginGuardKeepsMarkedRecoverySubmissionsReachable(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $csrfTokens = new CsrfTokenManager(); + $request = Request::create('/user/login', 'POST', [ + 'username' => 'owner', + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => (string) $csrfTokens->getToken(AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_ID), + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock, csrfTokens: $csrfTokens)->onKernelRequestLogin($event); + + self::assertFalse($event->hasResponse()); + } + public function testRecoveryLoginSubmissionsCanEstablishTrustedContextDespiteActiveBan(): void { $clock = new MockClock('2026-06-18 12:00:00'); @@ -211,9 +280,12 @@ public function testSubscriberRunsAfterSecurityContextButBeforeOrdinaryRateLimit $autoBan = AutoBanRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; $rateLimit = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; - self::assertSame(['onKernelRequest', 4], $autoBan); + self::assertSame(['onKernelRequestProbeCandidate', 4097], $autoBan[0]); + self::assertSame(['onKernelRequestLogin', 16], $autoBan[1]); + self::assertSame(['onKernelRequest', 4], $autoBan[2]); + self::assertGreaterThan($rateLimit[0][1], $autoBan[0][1]); self::assertSame(['onKernelRequestOrdinary', 3], $rateLimit[1]); - self::assertGreaterThan($rateLimit[1][1], $autoBan[1]); + self::assertGreaterThan($rateLimit[1][1], $autoBan[2][1]); } private function subscriber( diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php index fa25c6ac..d45b9a66 100644 --- a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -19,6 +19,7 @@ use App\Security\Abuse\ActionCostCatalogue; use App\Security\Abuse\RequestIntentClassifier; use App\Security\Abuse\SuspiciousProbePathMatcher; +use App\Security\AutoBan\AutoBanRequestSubscriber; use App\Security\RateLimit\RateLimitRequestSubscriber; use App\Security\RateLimit\RateLimitEnforcer; use App\Security\RateLimit\RateLimitLimiterFactory; @@ -157,6 +158,31 @@ public function testProbeHookSkipsFullEnforcerForNonProbePaths(): void self::assertFalse($event->hasResponse()); } + public function testProbeHookSkipsConsumptionWhenActiveAutoBanAlreadyMatched(): void + { + $enforcer = (new ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(); + $responses = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor(); + $subscriber = new RateLimitRequestSubscriber( + $enforcer, + $responses, + 'prod', + new SetupCompletionMarker(), + dirname(__DIR__, 3), + new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS), + ); + $request = Request::create('/.env'); + $request->attributes->set(AutoBanRequestSubscriber::PROBE_RATE_LIMIT_SKIP_ATTRIBUTE, true); + $event = new RequestEvent( + new RateLimitRequestSubscriberTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + + $subscriber->onKernelRequestProbe($event); + + self::assertFalse($event->hasResponse()); + } + public function testProbeHookUsesBareResponseBeforeSetupCompletion(): void { unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]); From 080942b38512f7c8396d71a8476fb9bafd5e5307 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 17:11:44 +0200 Subject: [PATCH 32/55] Release auto-bans before reset cutoffs --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + dev/draft/security-hardening/auto-ban.md | 4 +- .../security-hardening/policy-defaults.md | 2 +- src/Controller/AdminAutoBanController.php | 8 +-- .../AutoBan/Api/AutoBanApiHandler.php | 24 ++------- src/Security/AutoBan/AutoBanResetService.php | 54 +++++++++++++++++++ .../AutoBan/AutoBanResetServiceTest.php | 53 ++++++++++++++++++ 8 files changed, 121 insertions(+), 27 deletions(-) create mode 100644 src/Security/AutoBan/AutoBanResetService.php create mode 100644 tests/Security/AutoBan/AutoBanResetServiceTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 5b8d4908..fcf50952 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -202,7 +202,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, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted GET/POST attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted code-bearing forms, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS`, 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/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets trusted users plus recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and records Owner reset signals that clear active cache state and invalidate earlier scoring evidence. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets trusted users plus recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and releases active cache state before recording Owner reset cutoff signals while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index ca533383..dd02ca2f 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -104,6 +104,7 @@ - Addressed third Cloud Review round with separate reviewable commits: active auto-bans override earlier probe responses and set the passive-signal skip marker; malformed scalar request/query fields in the auto-ban inspection path can no longer make active-ban enforcement fail open; session/visitor binding now respects existing auto-ban responses and skip markers; and repeated Owner alerts for the same banned subject use occurrence-specific presentation IDs. - Added scoreable suspicious payload signals for obvious public/untrusted GET/POST abuse patterns and malformed scalar-only security parameters. The payload matcher records only safe pattern classes and bounded parameter metadata, never raw submitted values, and skips Admin/Editor/Setup/trusted contexts so legitimate code-bearing fields such as schema custom Twig are not treated as probes; active auto-ban `403` requests still suppress new Security signals but remain access-log entries for Request-ID/Visitor/IP audit correlation. - Enriched active auto-ban detail with country/continent from the latest retained ban-trigger signal's Request ID by reading the matching access-log projection. Security signals still do not duplicate raw IP or per-signal GeoIP values, and missing/expired access-log context falls back to `n/a`. +- Addressed fourth Cloud Review round with separate reviewable commits: active auto-ban checks no longer skip shared ignorable/static paths when those requests reach Symfony, probe candidates mark already-banned sources before suspicious-probe rate-limit consumption, ordinary login submissions are blocked before form authentication unless they carry the recovery marker, and manual reset now releases active state before recording the reset cutoff while restoring active state best-effort if the cutoff cannot be persisted. ### 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 025ca4d8..2ee28ef5 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -36,7 +36,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 6. Add bounded Owner-gated Config/Settings defaults through the existing settings registry/default provider for auto-ban enablement, trusted-user minimum access level, score threshold, Owner alert delivery for newly decided bans, and any required bounded policy constants so missing databases use seeded defaults and do not cause Doctrine/DBAL throws during setup or degraded states. 7. Add cache-flock-backed active ban state with TTL plus a cache-backed active-ban index for Admin list rendering. The ban store and index must serialize index mutations, fail open when cache/lock storage is unavailable, and must never create an invisible permanent block. 8. Emit a persistent `security_signal_event` record when a ban is triggered, including whether the effective subject was `visitor` or `ip`, the TTL/escalation context, score summary, and safe references needed for Admin review without exposing raw IPs, raw visitor-cookie tokens, headers, secrets, or raw credentials. -9. Emit a Security signal when an Owner manually resets a ban. Reset success must require observable reset-signal persistence and active cache-state release. Reset signals invalidate earlier retained signals for that same subject and subject type for future score and escalation calculations, so the visitor/IP starts at zero after reset. +9. Emit a Security signal when an Owner manually resets a ban. Reset success must require active cache-state release and observable reset-signal persistence. Release the active cache state first, then record the reset cutoff; if the reset signal cannot be persisted, restore the active state best-effort and report reset failure instead of leaving a cutoff for a ban that was not successfully released. Reset signals invalidate earlier retained signals for that same subject and subject type for future score and escalation calculations, so the visitor/IP starts at zero after reset. 10. Add enforcement early enough to run before controller/error-page rendering and before rate-limit buckets are consumed, but late enough that authenticated trusted users and trusted-user-owned API keys have been resolved and can bypass active Visitor/IP bans. Active temporary bans return the shared forced bare `403 Forbidden` response with `Retry-After`, generic message, and safe Request ID only. 11. Add Owner-gated Security settings UI fields for auto-ban enablement, trusted-user minimum access level, score threshold, and newly decided ban alerts. 12. Add the active-ban Admin list with subject type, safe subject label, created timestamp, TTL expiry, and detail link. The list and detail views use the existing non-configurable `admin.settings.security` ACL gate instead of a separate auto-ban gate. @@ -58,7 +58,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Score aggregation is write-triggered, not request-triggered. After a scoreable `security_signal_event` insert succeeds, the same DB connection may query retained rows for the affected Visitor/IP subjects using the existing `subject_type`, `subject_identifier`, and `occurred_at` index, apply the latest reset cutoff and one-hour window, and decide whether to write a ban-trigger signal plus cache-flock state. Requests with no new scoreable signal only perform the cheap active-ban cache check. - Auto-ban detail may show coarse GeoIP audit context for the banned subject by reading the latest retained ban-trigger signal's Request ID and joining that Request ID to the access-log projection. Security signals must not duplicate raw IP or per-signal GeoIP values; the detail view should expose only the trigger request's country and continent, with `n/a` fallback when access-log context is missing or expired. - Security-signal retention resets escalation naturally. Once prior ban-trigger signals expire or are reset, later bans start from the lower escalation tier again. -- Manual reset takes effect immediately by deleting/clearing active cache-flock ban state and recording a reset signal. Score and escalation queries ignore earlier signals at or before the latest reset signal for the same subject type/key. +- Manual reset takes effect by deleting/clearing active cache-flock ban state before recording the reset signal. If the reset signal cannot be persisted after release, the active state is restored best-effort and the reset is reported as failed. Score and escalation queries ignore earlier signals at or before the latest reset signal for the same subject type/key. - Threshold changes apply immediately for new decisions only. Existing active bans are not lifted automatically. If a subject is now above a lowered threshold but is not currently banned, the next qualifying Security signal triggers evaluation and may create the ban. This policy is intentional and should be preserved in reviews. - Score thresholds and suspicious-action weights must be floored so at least one action always gets through and a ban cannot be created before the second qualifying signal for that subject type. - The first implementation uses stable code defaults in a score catalogue, with settings only for enablement, trusted-user minimum access level, and score threshold. Per-signal weight tuning may become configurable later only at the catalogue boundary with tests and policy updates. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 67c406f2..3d9da318 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -182,7 +182,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - Triggered active ban TTLs escalate as `1h`, `3h`, `24h`, and `7d` for both Visitor-ID and IP subjects. - Escalation is derived from retained prior ban-trigger `security_signal_event` records for the same subject type/key. Ban-trigger signals must record whether the effective ban subject was `visitor` or `ip` so the escalation counters stay separate. - Security-signal retention resets escalation naturally. Manual reset records a reset Security signal; score and escalation queries ignore earlier signals at or before the latest reset for the same subject type/key. -- Manual reset also clears the active cache-flock ban state immediately and must be audited. +- Manual reset clears the active cache-flock ban state before recording the reset cutoff signal and must be audited. If the cutoff signal cannot be persisted after active release, the active state is restored best-effort and the reset is reported as failed so a still-active ban does not get under-scored later. - Threshold changes apply immediately for new ban decisions only. Existing active bans are not lifted automatically. If a subject is above a newly lowered threshold but is not yet banned, the next qualifying Security signal triggers re-evaluation and may create the ban. - Score thresholds and suspicious-action weights must be floored so at least one action always gets through and a ban cannot be created before the second qualifying signal for that subject type. - Initial score weights should live in a dedicated score catalogue similar to existing catalogue classes. Honeypot/probe, obvious attack-pattern payload, and copied-session signals may have high weights, while ordinary error-hit and rate-limit-hit weights must be conservative enough that a single legitimate mistake is harmless and repeated hits can still become suspicious. Payload scanning must skip Admin, Editor, Setup, and trusted-user contexts so legitimate code-bearing fields are not treated as probes. Payload signal evidence must be redacted to pattern classes and safe parameter metadata only. diff --git a/src/Controller/AdminAutoBanController.php b/src/Controller/AdminAutoBanController.php index 20b15990..d575d720 100644 --- a/src/Controller/AdminAutoBanController.php +++ b/src/Controller/AdminAutoBanController.php @@ -15,8 +15,8 @@ use App\Security\Abuse\SecuritySignalRecorder; use App\Security\AutoBan\ActiveAutoBan; use App\Security\AutoBan\AutoBanAdminBrowser; +use App\Security\AutoBan\AutoBanResetService; use App\Security\AutoBan\AutoBanScoreCatalogue; -use App\Security\AutoBan\AutoBanStore; use App\View\Alert\UiAlertDelivery; use App\View\Alert\UiAlertDispatcherInterface; use App\View\Alert\UiAlertTranslation; @@ -33,7 +33,7 @@ public function __construct( private readonly BackendAccessGuard $accessGuard, private readonly AdminFeatureAccessPolicy $adminAcl, private readonly AutoBanAdminBrowser $browser, - private readonly AutoBanStore $store, + private readonly AutoBanResetService $resetService, private readonly SecuritySignalRecorder $signals, private readonly HttpErrorRenderer $httpError, private readonly FormTokenValidator $formTokenValidator, @@ -87,8 +87,8 @@ public function reset(Request $request, string $key): Response return $this->httpError->resolve(Response::HTTP_FORBIDDEN, $request, context: ['auto_ban_key' => $key]); } - $ban = $this->store->activeByKey($key); - if ($ban instanceof ActiveAutoBan && $this->recordResetSignal($request, $key, $ban) && null !== $this->store->reset($key)) { + $ban = $this->resetService->releaseAndRecord($key, fn (ActiveAutoBan $released): bool => $this->recordResetSignal($request, $key, $released)); + if ($ban instanceof ActiveAutoBan) { $this->auditReset($key, $ban->subjectType()); $this->alerts->addAlert(UiAlertTranslation::success('admin.auto_bans.reset.saved'), UiAlertDelivery::Direct); } else { diff --git a/src/Security/AutoBan/Api/AutoBanApiHandler.php b/src/Security/AutoBan/Api/AutoBanApiHandler.php index 6b8020a9..f3595c13 100644 --- a/src/Security/AutoBan/Api/AutoBanApiHandler.php +++ b/src/Security/AutoBan/Api/AutoBanApiHandler.php @@ -19,8 +19,8 @@ use App\Security\Abuse\SecuritySignalRecorder; use App\Security\AutoBan\ActiveAutoBan; use App\Security\AutoBan\AutoBanAdminBrowser; +use App\Security\AutoBan\AutoBanResetService; use App\Security\AutoBan\AutoBanScoreCatalogue; -use App\Security\AutoBan\AutoBanStore; use App\Security\SecurityMessageCode; use App\Security\SecurityMessageKey; use Symfony\Component\HttpFoundation\Request; @@ -31,7 +31,7 @@ { public function __construct( private AutoBanAdminBrowser $browser, - private AutoBanStore $store, + private AutoBanResetService $resetService, private SecuritySignalRecorder $signals, private AccessRequestMetadata $requestMetadata, private AuditLoggerInterface $auditLogger, @@ -75,25 +75,11 @@ private function reset(Request $request, string $key, ApiEndpointDefinition $end return $denied; } - $ban = $this->store->activeByKey($key); - if (null === $ban) { + $ban = $this->resetService->releaseAndRecord($key, fn (ActiveAutoBan $released): bool => $this->recordResetSignal($request, $endpoint, $key, $released)); + if (!$ban instanceof ActiveAutoBan) { return $this->operationUnavailable($request, $endpoint->operationId(), [ 'active_ban_key' => $key, - 'reason' => 'active_ban_not_found', - ]); - } - - if (!$this->recordResetSignal($request, $endpoint, $key, $ban)) { - return $this->operationUnavailable($request, $endpoint->operationId(), [ - 'active_ban_key' => $key, - 'reason' => 'reset_signal_not_recorded', - ]); - } - - if (null === $this->store->reset($key)) { - return $this->operationUnavailable($request, $endpoint->operationId(), [ - 'active_ban_key' => $key, - 'reason' => 'active_ban_reset_failed', + 'reason' => 'active_ban_reset_failed_or_signal_not_recorded', ]); } diff --git a/src/Security/AutoBan/AutoBanResetService.php b/src/Security/AutoBan/AutoBanResetService.php new file mode 100644 index 00000000..39b44a09 --- /dev/null +++ b/src/Security/AutoBan/AutoBanResetService.php @@ -0,0 +1,54 @@ +store->reset($key); + if (!$released instanceof ActiveAutoBan) { + return null; + } + + try { + if ($recordResetSignal($released)) { + return $released; + } + } catch (Throwable) { + } + + $this->restore($released); + + return null; + } + + private function restore(ActiveAutoBan $ban): void + { + $subject = new AutoBanSubject( + $ban->subjectType(), + $ban->subjectIdentifier(), + AutoBanSubject::IP === $ban->subjectType(), + ); + + $this->store->createOrReturnActive($subject, $ban->retryAfterSeconds($this->clock->now()), [ + ...$ban->context(), + 'restored_after_failed_reset_signal' => true, + ]); + } +} diff --git a/tests/Security/AutoBan/AutoBanResetServiceTest.php b/tests/Security/AutoBan/AutoBanResetServiceTest.php new file mode 100644 index 00000000..59b677f9 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanResetServiceTest.php @@ -0,0 +1,53 @@ +ban($subject, 3600); + self::assertNotNull($ban); + + $released = (new AutoBanResetService($store, $clock))->releaseAndRecord($ban->key(), function (ActiveAutoBan $released) use ($store, $subject): bool { + self::assertNull($store->active($subject)); + + return $released->key() !== ''; + }); + + self::assertInstanceOf(ActiveAutoBan::class, $released); + self::assertNull($store->active($subject)); + } + + public function testItRestoresActiveStateWhenResetSignalCannotBeRecorded(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::IP, 'ip-reset-service', true); + $ban = $store->ban($subject, 3600, ['score' => 100]); + self::assertNotNull($ban); + + $released = (new AutoBanResetService($store, $clock))->releaseAndRecord($ban->key(), static fn (): bool => false); + + self::assertNull($released); + $restored = $store->active($subject); + self::assertInstanceOf(ActiveAutoBan::class, $restored); + self::assertSame($ban->key(), $restored->key()); + self::assertTrue($restored->context()['restored_after_failed_reset_signal'] ?? false); + } +} From fb9efbf284b10239e48aa5e20ad24adeb8adcd70 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 17:36:06 +0200 Subject: [PATCH 33/55] Serialize auto-ban reset cutoffs --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + dev/draft/security-hardening/auto-ban.md | 4 +- .../security-hardening/policy-defaults.md | 2 +- src/Security/AutoBan/AutoBanResetService.php | 35 +------ src/Security/AutoBan/AutoBanStore.php | 94 +++++++++++++++++++ .../AutoBan/AutoBanResetServiceTest.php | 23 ++++- 7 files changed, 121 insertions(+), 40 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index fcf50952..a02ccc03 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -202,7 +202,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, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted GET/POST attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted code-bearing forms, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS`, 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/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets trusted users plus recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and releases active cache state before recording Owner reset cutoff signals while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets trusted users plus recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index dd02ca2f..a2540597 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -105,6 +105,7 @@ - Added scoreable suspicious payload signals for obvious public/untrusted GET/POST abuse patterns and malformed scalar-only security parameters. The payload matcher records only safe pattern classes and bounded parameter metadata, never raw submitted values, and skips Admin/Editor/Setup/trusted contexts so legitimate code-bearing fields such as schema custom Twig are not treated as probes; active auto-ban `403` requests still suppress new Security signals but remain access-log entries for Request-ID/Visitor/IP audit correlation. - Enriched active auto-ban detail with country/continent from the latest retained ban-trigger signal's Request ID by reading the matching access-log projection. Security signals still do not duplicate raw IP or per-signal GeoIP values, and missing/expired access-log context falls back to `n/a`. - Addressed fourth Cloud Review round with separate reviewable commits: active auto-ban checks no longer skip shared ignorable/static paths when those requests reach Symfony, probe candidates mark already-banned sources before suspicious-probe rate-limit consumption, ordinary login submissions are blocked before form authentication unless they carry the recovery marker, and manual reset now releases active state before recording the reset cutoff while restoring active state best-effort if the cutoff cannot be persisted. +- Addressed the fifth Cloud Review reset race by moving reset release plus cutoff recording under the same subject-key lock used for ban creation, so concurrent score evaluation cannot recreate an active ban from pre-reset evidence between cache release and reset-signal persistence. ### 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 2ee28ef5..d8a9bf7a 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -36,7 +36,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 6. Add bounded Owner-gated Config/Settings defaults through the existing settings registry/default provider for auto-ban enablement, trusted-user minimum access level, score threshold, Owner alert delivery for newly decided bans, and any required bounded policy constants so missing databases use seeded defaults and do not cause Doctrine/DBAL throws during setup or degraded states. 7. Add cache-flock-backed active ban state with TTL plus a cache-backed active-ban index for Admin list rendering. The ban store and index must serialize index mutations, fail open when cache/lock storage is unavailable, and must never create an invisible permanent block. 8. Emit a persistent `security_signal_event` record when a ban is triggered, including whether the effective subject was `visitor` or `ip`, the TTL/escalation context, score summary, and safe references needed for Admin review without exposing raw IPs, raw visitor-cookie tokens, headers, secrets, or raw credentials. -9. Emit a Security signal when an Owner manually resets a ban. Reset success must require active cache-state release and observable reset-signal persistence. Release the active cache state first, then record the reset cutoff; if the reset signal cannot be persisted, restore the active state best-effort and report reset failure instead of leaving a cutoff for a ban that was not successfully released. Reset signals invalidate earlier retained signals for that same subject and subject type for future score and escalation calculations, so the visitor/IP starts at zero after reset. +9. Emit a Security signal when an Owner manually resets a ban. Reset success must require active cache-state release and observable reset-signal persistence. Serialize the release and reset-cutoff write with ban creation for the same subject key, release the active cache state first, then record the reset cutoff; if the reset signal cannot be persisted, restore the active state best-effort and report reset failure instead of leaving a cutoff for a ban that was not successfully released. Reset signals invalidate earlier retained signals for that same subject and subject type for future score and escalation calculations, so the visitor/IP starts at zero after reset. 10. Add enforcement early enough to run before controller/error-page rendering and before rate-limit buckets are consumed, but late enough that authenticated trusted users and trusted-user-owned API keys have been resolved and can bypass active Visitor/IP bans. Active temporary bans return the shared forced bare `403 Forbidden` response with `Retry-After`, generic message, and safe Request ID only. 11. Add Owner-gated Security settings UI fields for auto-ban enablement, trusted-user minimum access level, score threshold, and newly decided ban alerts. 12. Add the active-ban Admin list with subject type, safe subject label, created timestamp, TTL expiry, and detail link. The list and detail views use the existing non-configurable `admin.settings.security` ACL gate instead of a separate auto-ban gate. @@ -58,7 +58,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Score aggregation is write-triggered, not request-triggered. After a scoreable `security_signal_event` insert succeeds, the same DB connection may query retained rows for the affected Visitor/IP subjects using the existing `subject_type`, `subject_identifier`, and `occurred_at` index, apply the latest reset cutoff and one-hour window, and decide whether to write a ban-trigger signal plus cache-flock state. Requests with no new scoreable signal only perform the cheap active-ban cache check. - Auto-ban detail may show coarse GeoIP audit context for the banned subject by reading the latest retained ban-trigger signal's Request ID and joining that Request ID to the access-log projection. Security signals must not duplicate raw IP or per-signal GeoIP values; the detail view should expose only the trigger request's country and continent, with `n/a` fallback when access-log context is missing or expired. - Security-signal retention resets escalation naturally. Once prior ban-trigger signals expire or are reset, later bans start from the lower escalation tier again. -- Manual reset takes effect by deleting/clearing active cache-flock ban state before recording the reset signal. If the reset signal cannot be persisted after release, the active state is restored best-effort and the reset is reported as failed. Score and escalation queries ignore earlier signals at or before the latest reset signal for the same subject type/key. +- Manual reset takes effect by deleting/clearing active cache-flock ban state before recording the reset signal, while holding the same subject-key lock used for ban creation. If the reset signal cannot be persisted after release, the active state is restored best-effort and the reset is reported as failed. Score and escalation queries ignore earlier signals at or before the latest reset signal for the same subject type/key. - Threshold changes apply immediately for new decisions only. Existing active bans are not lifted automatically. If a subject is now above a lowered threshold but is not currently banned, the next qualifying Security signal triggers evaluation and may create the ban. This policy is intentional and should be preserved in reviews. - Score thresholds and suspicious-action weights must be floored so at least one action always gets through and a ban cannot be created before the second qualifying signal for that subject type. - The first implementation uses stable code defaults in a score catalogue, with settings only for enablement, trusted-user minimum access level, and score threshold. Per-signal weight tuning may become configurable later only at the catalogue boundary with tests and policy updates. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index 3d9da318..e5b9ace8 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -182,7 +182,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - Triggered active ban TTLs escalate as `1h`, `3h`, `24h`, and `7d` for both Visitor-ID and IP subjects. - Escalation is derived from retained prior ban-trigger `security_signal_event` records for the same subject type/key. Ban-trigger signals must record whether the effective ban subject was `visitor` or `ip` so the escalation counters stay separate. - Security-signal retention resets escalation naturally. Manual reset records a reset Security signal; score and escalation queries ignore earlier signals at or before the latest reset for the same subject type/key. -- Manual reset clears the active cache-flock ban state before recording the reset cutoff signal and must be audited. If the cutoff signal cannot be persisted after active release, the active state is restored best-effort and the reset is reported as failed so a still-active ban does not get under-scored later. +- Manual reset clears the active cache-flock ban state before recording the reset cutoff signal and must be audited. Release plus cutoff persistence must hold the same subject-key lock used for ban creation so a concurrent qualifying signal cannot recreate the ban from pre-reset evidence in between. If the cutoff signal cannot be persisted after active release, the active state is restored best-effort and the reset is reported as failed so a still-active ban does not get under-scored later. - Threshold changes apply immediately for new ban decisions only. Existing active bans are not lifted automatically. If a subject is above a newly lowered threshold but is not yet banned, the next qualifying Security signal triggers re-evaluation and may create the ban. - Score thresholds and suspicious-action weights must be floored so at least one action always gets through and a ban cannot be created before the second qualifying signal for that subject type. - Initial score weights should live in a dedicated score catalogue similar to existing catalogue classes. Honeypot/probe, obvious attack-pattern payload, and copied-session signals may have high weights, while ordinary error-hit and rate-limit-hit weights must be conservative enough that a single legitimate mistake is harmless and repeated hits can still become suspicious. Payload scanning must skip Admin, Editor, Setup, and trusted-user contexts so legitimate code-bearing fields are not treated as probes. Payload signal evidence must be redacted to pattern classes and safe parameter metadata only. diff --git a/src/Security/AutoBan/AutoBanResetService.php b/src/Security/AutoBan/AutoBanResetService.php index 39b44a09..384eb6b9 100644 --- a/src/Security/AutoBan/AutoBanResetService.php +++ b/src/Security/AutoBan/AutoBanResetService.php @@ -4,15 +4,10 @@ namespace App\Security\AutoBan; -use Symfony\Component\Clock\ClockInterface; -use Symfony\Component\Clock\NativeClock; -use Throwable; - final readonly class AutoBanResetService { public function __construct( private AutoBanStore $store, - private ClockInterface $clock = new NativeClock(), ) { } @@ -21,34 +16,6 @@ public function __construct( */ public function releaseAndRecord(string $key, callable $recordResetSignal): ?ActiveAutoBan { - $released = $this->store->reset($key); - if (!$released instanceof ActiveAutoBan) { - return null; - } - - try { - if ($recordResetSignal($released)) { - return $released; - } - } catch (Throwable) { - } - - $this->restore($released); - - return null; - } - - private function restore(ActiveAutoBan $ban): void - { - $subject = new AutoBanSubject( - $ban->subjectType(), - $ban->subjectIdentifier(), - AutoBanSubject::IP === $ban->subjectType(), - ); - - $this->store->createOrReturnActive($subject, $ban->retryAfterSeconds($this->clock->now()), [ - ...$ban->context(), - 'restored_after_failed_reset_signal' => true, - ]); + return $this->store->resetAndRecord($key, $recordResetSignal); } } diff --git a/src/Security/AutoBan/AutoBanStore.php b/src/Security/AutoBan/AutoBanStore.php index fddd4dcf..cefeb88e 100644 --- a/src/Security/AutoBan/AutoBanStore.php +++ b/src/Security/AutoBan/AutoBanStore.php @@ -125,6 +125,39 @@ public function activeByKey(string $key): ?ActiveAutoBan } public function reset(string $key): ?ActiveAutoBan + { + return $this->withSubjectLock($key, fn (): ?ActiveAutoBan => $this->resetUnlocked($key), 'reset'); + } + + /** + * @param callable(ActiveAutoBan): bool $recordResetSignal + */ + public function resetAndRecord(string $key, callable $recordResetSignal): ?ActiveAutoBan + { + return $this->withSubjectLock($key, function () use ($key, $recordResetSignal): ?ActiveAutoBan { + $ban = $this->resetUnlocked($key); + if (!$ban instanceof ActiveAutoBan) { + return null; + } + + try { + if ($recordResetSignal($ban)) { + return $ban; + } + } catch (Throwable $error) { + $this->reportStorage('reset_signal', $error, ['active_ban_key' => $key]); + } + + $this->restoreActiveState($ban, [ + ...$ban->context(), + 'restored_after_failed_reset_signal' => true, + ]); + + return null; + }, 'reset_cutoff'); + } + + private function resetUnlocked(string $key): ?ActiveAutoBan { try { $ban = $this->activeByKey($key); @@ -214,6 +247,67 @@ private function rollbackActiveState(ActiveAutoBan $ban): void } } + /** + * @param array $context + */ + private function restoreActiveState(ActiveAutoBan $ban, array $context): void + { + $restored = new ActiveAutoBan( + $ban->key(), + $ban->subjectType(), + $ban->subjectIdentifier(), + $ban->createdAt(), + $ban->expiresAt(), + $ban->ttlSeconds(), + $context, + ); + + try { + $item = $this->cache->getItem($this->cacheKey($restored->key())); + $item->set($restored->toArray()); + $item->expiresAfter($restored->retryAfterSeconds($this->clock->now())); + if (!$this->cache->save($item)) { + $this->reportStorage('reset_restore_save', new \RuntimeException('Active auto-ban restore cache save failed.'), ['active_ban_key' => $restored->key()]); + + return; + } + + if (!$this->upsertIndex($restored)) { + $this->rollbackActiveState($restored); + } + } catch (Throwable $error) { + $this->reportStorage('reset_restore', $error, ['active_ban_key' => $restored->key()]); + } + } + + /** + * @param callable(): ?ActiveAutoBan $operation + */ + private function withSubjectLock(string $key, callable $operation, string $operationName): ?ActiveAutoBan + { + $lock = $this->lockFactory->createLock(self::KEY_PREFIX.$key, 5.0); + + try { + if (!$lock->acquire(true)) { + $this->reportStorage($operationName, new \RuntimeException('Auto-ban subject lock unavailable.'), ['active_ban_key' => $key]); + + return null; + } + + return $operation(); + } catch (Throwable $error) { + $this->reportStorage($operationName, $error, ['active_ban_key' => $key]); + + return null; + } finally { + try { + $lock->release(); + } catch (Throwable $error) { + $this->reportStorage($operationName.'_lock_release', $error, ['active_ban_key' => $key]); + } + } + } + private function upsertIndex(ActiveAutoBan $ban): bool { return $this->updateIndex(static function (array $index) use ($ban): array { diff --git a/tests/Security/AutoBan/AutoBanResetServiceTest.php b/tests/Security/AutoBan/AutoBanResetServiceTest.php index 59b677f9..fef8f0d2 100644 --- a/tests/Security/AutoBan/AutoBanResetServiceTest.php +++ b/tests/Security/AutoBan/AutoBanResetServiceTest.php @@ -24,7 +24,7 @@ public function testItRecordsResetOnlyAfterActiveStateWasReleased(): void $ban = $store->ban($subject, 3600); self::assertNotNull($ban); - $released = (new AutoBanResetService($store, $clock))->releaseAndRecord($ban->key(), function (ActiveAutoBan $released) use ($store, $subject): bool { + $released = (new AutoBanResetService($store))->releaseAndRecord($ban->key(), function (ActiveAutoBan $released) use ($store, $subject): bool { self::assertNull($store->active($subject)); return $released->key() !== ''; @@ -42,7 +42,7 @@ public function testItRestoresActiveStateWhenResetSignalCannotBeRecorded(): void $ban = $store->ban($subject, 3600, ['score' => 100]); self::assertNotNull($ban); - $released = (new AutoBanResetService($store, $clock))->releaseAndRecord($ban->key(), static fn (): bool => false); + $released = (new AutoBanResetService($store))->releaseAndRecord($ban->key(), static fn (): bool => false); self::assertNull($released); $restored = $store->active($subject); @@ -50,4 +50,23 @@ public function testItRestoresActiveStateWhenResetSignalCannotBeRecorded(): void self::assertSame($ban->key(), $restored->key()); self::assertTrue($restored->context()['restored_after_failed_reset_signal'] ?? false); } + + public function testItSerializesReleaseAndCutoffAgainstBanCreation(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-reset-race'); + $ban = $store->ban($subject, 3600, ['score' => 100]); + self::assertNotNull($ban); + + $released = (new AutoBanResetService($store))->releaseAndRecord($ban->key(), function () use ($store, $subject): bool { + self::assertNull($store->active($subject)); + self::assertNull($store->createOrReturnActive($subject, 7200, ['score' => 200])); + + return true; + }); + + self::assertInstanceOf(ActiveAutoBan::class, $released); + self::assertNull($store->active($subject)); + } } From 1846d3c1a6b7bc259f68c396849730b7e3b1cef2 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 17:52:39 +0200 Subject: [PATCH 34/55] Preempt banned API and scheduler sources --- config/services.yaml | 1 + dev/CLASSMAP.md | 6 +- dev/WORKLOG.md | 1 + dev/draft/security-hardening/auto-ban.md | 12 +-- .../security-hardening/policy-defaults.md | 2 +- src/Api/Security/ApiKeyAuthenticator.php | 64 +------------ src/Api/Security/ApiKeyCredentialResolver.php | 90 +++++++++++++++++++ .../AutoBan/AutoBanRequestSubscriber.php | 54 +++++++++++ .../AutoBan/TrustedApiKeyAutoBanBypass.php | 50 +++++++++++ .../RateLimitAuthenticationSubscriber.php | 5 ++ .../RateLimitEnforcementControllerTest.php | 72 +++++++++++++++ tests/Controller/SchedulerControllerTest.php | 84 +++++++++++++++++ .../AutoBan/AutoBanRequestSubscriberTest.php | 70 ++++++++++++++- .../RateLimitAuthenticationSubscriberTest.php | 75 ++++++++++++++++ 14 files changed, 511 insertions(+), 75 deletions(-) create mode 100644 src/Api/Security/ApiKeyCredentialResolver.php create mode 100644 src/Security/AutoBan/TrustedApiKeyAutoBanBypass.php create mode 100644 tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php diff --git a/config/services.yaml b/config/services.yaml index 9516bfa3..5130025d 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -281,6 +281,7 @@ services: App\Security\AutoBan\AutoBanRequestSubscriber: arguments: $environment: '%kernel.environment%' + $trustedApiKeys: '@App\Security\AutoBan\TrustedApiKeyAutoBanBypass' App\Security\RateLimit\RateLimitRequestSubscriber: arguments: diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index a02ccc03..96df6239 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -62,7 +62,7 @@ | 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`, `App\Core\Routing\IgnorableRequestPathMatcher` | Shared segment-bound path-scope matchers for raw technical route scopes, request-aware URL locale-prefix stripping only for locale-prefix UI/account scopes, and static/tooling/well-known request paths that should not spend rate-limit budget, access-statistics rows, active-ban checks, or passive error-status Security signals, so protected prefixes such as `/api/v1`, `/cron`, generated assets, profiler, toolbar paths, access-log surfaces, request intents, and abuse-subject workflows cannot accidentally match lookalike public content paths or localized non-technical aliases. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Routing/PathScopeMatcherTest.php`, `tests/Core/Routing/RequestPathResolverTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Service/contract/controller/Twig | `App\Privacy\Cookie\CookieConsentDefinition`, `App\Privacy\Cookie\CookieConsentProviderInterface`, `App\Privacy\Cookie\CookieConsentRegistry`, `App\Privacy\Cookie\CookieConsentManager`, `App\Privacy\Cookie\ConsentCookieJar`, `App\Privacy\Cookie\CookieConsentResponseSubscriber`, `App\Privacy\Cookie\CookieConsentTwigExtension`, `App\Controller\CookieConsentController`, `templates/components/CookieConsent.html.twig`, `assets/controllers/cookie_consent_controller.js` | Provides a package-extendable cookie consent registry with duplicate-name rejection, package-load duplicate/core-cookie collision faulting, HTTP(S)/relative-only optional-cookie privacy links, central safe cookie get/set gate with registered cookie identity and policy-attribute enforcement, very-late response-time removal of registered optional cookies without stored consent while preserving explicit clear-cookie headers, explicit expiration of every rejected optional cookie, DNT/GPC-aware defaults, visitor-bound stateless HMAC CSRF protection that does not create anonymous sessions, signed TTL-validated system-owned consent-cookie persistence, selected optional-cookie state for later edits, safe relative-only consent redirects, reusable `cookie_consent_trigger_attributes()` links, and a frontend banner/overlay that only auto-opens when optional cookies are registered without stored consent. | `dev/draft/0.2.x-SecurityAccessControl.md`, `docs/**` | `tests/assets/controller_foundation.test.mjs`, `tests/Privacy/Cookie/CookieConsentManagerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php` | -| API foundation/security | `App\Api\ApiFeaturePolicy`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Api\Security\ApiRequestMethodPolicy`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber` | Provides the versioned `/api/v1` runtime boundary with optional stateless Bearer API-key authentication, config-controlled availability and CORS handling that keeps anonymous preflights cheap while letting actual `Authorization` preflights reach rate-limit handling by requested method, request-scoped authenticated or anonymous API context, shared effective-method resolution for credentialed/read-only/CORS preflight decisions, endpoint-derived minimum-access checks, JSON content-type enforcement, setup/maintenance/database/disabled `503` JSON responses, trace headers, localized Message-layer data/error responses, JSON object request parsing, and shared list query normalization. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Api/Security/ApiRequestMethodPolicyTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | +| API foundation/security | `App\Api\ApiFeaturePolicy`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyCredentialResolver`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Api\Security\ApiRequestMethodPolicy`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber` | Provides the versioned `/api/v1` runtime boundary with optional stateless Bearer API-key authentication through a shared HMAC-backed credential resolver, config-controlled availability and CORS handling that keeps anonymous preflights cheap while letting actual `Authorization` preflights reach rate-limit handling by requested method, request-scoped authenticated or anonymous API context, shared effective-method resolution for credentialed/read-only/CORS preflight decisions, endpoint-derived minimum-access checks, JSON content-type enforcement, setup/maintenance/database/disabled `503` JSON responses, trace headers, localized Message-layer data/error responses, JSON object request parsing, and shared list query normalization. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Api/Security/ApiRequestMethodPolicyTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | | API endpoint registry/documentation | `App\Api\Endpoint\ApiEndpointProviderInterface`, `App\Api\Endpoint\ApiEndpointHandlerInterface`, `App\Api\Endpoint\ApiEndpointDefinition`, `App\Api\Endpoint\ApiEndpointAccessPolicy`, `App\Api\Endpoint\ApiEndpointRegistry`, `App\Api\Endpoint\ApiEndpointHandlerRegistry`, `App\Api\Endpoint\ApiEndpointNavigationBuilder`, `App\Api\Endpoint\CoreApiEndpointProvider`, `App\Api\Endpoint\ApiListQueryParameterDefinition`, `App\Api\Endpoint\PackageApiEndpointPath`, `App\Api\Documentation\OpenApiDocumentFactory`, `App\Controller\ApiEndpointController`, `App\Controller\ApiRootController`, `App\Controller\ApiDocumentationController` | Aggregates domain-owned endpoint definitions and handlers through service tags, enforces public safe-method registration, supports explicit anonymous read opt-ins and minimum access levels, dispatches exact paths before broad pattern matches, exposes navigable API root/parent resources with access metadata, and generates OpenAPI 3.2 documents with manifest metadata, server entries, shell/domain tag hierarchy, neutral `x-access` operation metadata, shared schemas, error responses, and trace-header documentation. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Documentation/OpenApiDocumentFactoryTest.php`, `tests/Api/Endpoint/ApiEndpointAccessPolicyTest.php`, `tests/Api/Endpoint/ApiEndpointNavigationBuilderTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryWiringTest.php`, `tests/Controller/ApiFoundationControllerTest.php` | | Admin/settings API | `App\Api\Admin\AdminApiEndpointProvider`, `App\Api\Admin\AdminApiIndexHandler`, `App\Api\Admin\AdminPermissionMatrixApiHandler`, `App\Api\Admin\AdminPermissionMatrixReadModel`, `App\Api\Admin\AdminOperationalApiEndpointProvider`, `App\Api\Admin\AdminDeferredApiHandler`, `App\Api\Admin\AdminLogApiHandler`, `App\Api\Admin\AdminOperationApiHandler`, `App\Api\Admin\AdminSchedulerApiHandler`, `App\Api\Admin\AdminStatisticsApiHandler`, `App\Api\Admin\AdminThemeApiHandler`, `App\Api\Admin\LiveOperationApiResourceFactory`, `App\Core\Config\Api\SettingsApiEndpointProvider`, `App\Core\Config\Api\SettingsApiHandler`, `App\Core\Config\Api\SettingsApiReadModel` | Provides navigable admin API endpoints under `/api/v1/admin`, endpoint permission matrices, settings-section read/update models through the existing settings form handler and Admin ACL feature states, secret-redacted settings API output, log-source read models, live-operation detail/continuation resources with status/continue/confirm links, confirm-gated operation maintenance actions, scheduler task detail/history/update/run-now endpoints, and Admin ACL-gated package lifecycle review/confirmation endpoints that start LiveOperation runs only after the caller is authorized. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php`, `tests/Api/Admin/LiveOperationApiResourceFactoryTest.php` | | Content API | `App\Content\Api\ContentApiEndpointProvider`, `App\Content\Api\ContentApiNavigationHandler`, `App\Content\Api\ContentApiPath`, `App\Content\Api\ContentApiItemListQuery`, `App\Content\Api\ContentApiVisibleItemPager`, `App\Content\Api\ContentApiItemReadModel`, `App\Content\Api\ContentApiItemHandler`, `App\Content\Api\ContentApiMutationStubHandler`, `App\Content\Api\SchemaApiEndpointProvider`, `App\Content\Api\SchemaApiReadModel`, `App\Content\Api\SchemaApiHandler` | Provides collision-free content API dynamic resources below `items/`, ACL-aware published content navigation/items/detail paths with child, variant, and revision navigation, query-backed published content collection pagination/filtering/sorting after ACL filtering, deferred non-published status read surfaces, deferred content mutation command stubs, and author-level schema metadata including custom Twig. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/ApiContentSchemaControllerTest.php`, `tests/Controller/ApiContentItemControllerTest.php` | @@ -201,8 +201,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 high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted GET/POST attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted code-bearing forms, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | -| Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS`, 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/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, enforces active bans after trusted session/API context but before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets trusted users plus recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php` | +| Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS` unless an active auto-ban response already owns the request, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index a2540597..8c3c42cf 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -106,6 +106,7 @@ - Enriched active auto-ban detail with country/continent from the latest retained ban-trigger signal's Request ID by reading the matching access-log projection. Security signals still do not duplicate raw IP or per-signal GeoIP values, and missing/expired access-log context falls back to `n/a`. - Addressed fourth Cloud Review round with separate reviewable commits: active auto-ban checks no longer skip shared ignorable/static paths when those requests reach Symfony, probe candidates mark already-banned sources before suspicious-probe rate-limit consumption, ordinary login submissions are blocked before form authentication unless they carry the recovery marker, and manual reset now releases active state before recording the reset cutoff while restoring active state best-effort if the cutoff cannot be persisted. - Addressed the fifth Cloud Review reset race by moving reset release plus cutoff recording under the same subject-key lock used for ban creation, so concurrent score evaluation cannot recreate an active ban from pre-reset evidence between cache release and reset-signal persistence. +- Addressed the fifth Cloud Review API-auth ordering issue by adding an early `/api/v1/**` and raw `/cron/run` active-ban guard that blocks invalid/untrusted credentials and credentialed preflights before auth failure/success side effects, while a shared HMAC-backed credential resolver allows active trusted-user-owned API keys, including scheduler `?auth=` when enabled, to bypass the source ban under the trusted-user rule before downstream API/scheduler authorization runs. ### 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 d8a9bf7a..1f2a25bd 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -37,7 +37,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 7. Add cache-flock-backed active ban state with TTL plus a cache-backed active-ban index for Admin list rendering. The ban store and index must serialize index mutations, fail open when cache/lock storage is unavailable, and must never create an invisible permanent block. 8. Emit a persistent `security_signal_event` record when a ban is triggered, including whether the effective subject was `visitor` or `ip`, the TTL/escalation context, score summary, and safe references needed for Admin review without exposing raw IPs, raw visitor-cookie tokens, headers, secrets, or raw credentials. 9. Emit a Security signal when an Owner manually resets a ban. Reset success must require active cache-state release and observable reset-signal persistence. Serialize the release and reset-cutoff write with ban creation for the same subject key, release the active cache state first, then record the reset cutoff; if the reset signal cannot be persisted, restore the active state best-effort and report reset failure instead of leaving a cutoff for a ban that was not successfully released. Reset signals invalidate earlier retained signals for that same subject and subject type for future score and escalation calculations, so the visitor/IP starts at zero after reset. -10. Add enforcement early enough to run before controller/error-page rendering and before rate-limit buckets are consumed, but late enough that authenticated trusted users and trusted-user-owned API keys have been resolved and can bypass active Visitor/IP bans. Active temporary bans return the shared forced bare `403 Forbidden` response with `Retry-After`, generic message, and safe Request ID only. +10. Add enforcement early enough to run before controller/error-page rendering and before rate-limit buckets are consumed. `/api/v1/**` requests with an active source ban must be stopped before normal API-key authentication, authentication-failure signal/rate handling, CORS preflight success, or API controller handling unless a narrow early Bearer lookup proves the key is active and owned by a user at or above the trusted-user level. The raw `/cron/run` scheduler trigger uses the same early source-ban guard; valid trusted-user-owned Bearer keys and, when scheduler GET auth is enabled, valid trusted-user-owned `?auth=` keys may pass through to the scheduler's own read-write/Admin authorization checks. Invalid, revoked, unavailable, or below-trusted API keys must not pass through an active Visitor/IP ban. Browser enforcement still preserves the documented recovery-login render and marked-submit path. Active temporary bans return the shared forced bare `403 Forbidden` response with `Retry-After`, generic message, and safe Request ID only. 11. Add Owner-gated Security settings UI fields for auto-ban enablement, trusted-user minimum access level, score threshold, and newly decided ban alerts. 12. Add the active-ban Admin list with subject type, safe subject label, created timestamp, TTL expiry, and detail link. The list and detail views use the existing non-configurable `admin.settings.security` ACL gate instead of a separate auto-ban gate. 13. Add the ban detail page with retained Security signals explaining the decision and an Owner-gated manual reset button. Detail rows are newest-first and may include retained pre-reset history for review context, but historical rows must never displace newer post-reset/current evidence from the bounded view. @@ -65,9 +65,9 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - The score is global per subject type/key, not separated into multiple buckets. Signal reasons decide weight; bucket family is diagnostic context only. - Evaluated Security signals are limited to source-risk signals. Routine access logs, ordinary successful requests, expected validation failures, login-required `401` responses, and shared ignorable static/tooling/well-known paths do not contribute by status alone. Normal application `404`, `403`, and `429` responses can still be weak signals because repeated misses, denials, or limiter collisions in a short window are source risk. - Honeypot/probe and obvious malformed/attack-pattern payload signals may carry high scores because they represent high-confidence scanner behavior. Payload scanning is limited to public/untrusted request surfaces and must skip Admin, Editor, Setup, and trusted-user contexts so legitimate code/template/content fields such as schema custom Twig are not recorded as probes. Payload signal context must store only pattern classes and safe parameter names/sources, never submitted raw values. Ordinary error-page and rate-limit hits must stay low enough that a single legitimate mistake is harmless while repeated hits can still cross the threshold. -- Trusted registered users are never auto-banned. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, the required setting also protects Owners from self-lockout through auto-ban. Valid API keys owned by trusted users inherit this bypass because the trusted user context has been resolved before active ban enforcement. +- Trusted registered users are never selected as auto-ban subjects. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, the required setting also protects Owners from being newly banned through trusted browser context. API keys are not active auto-ban subjects; `/api/v1/**` and raw `/cron/run` requests from an already banned Visitor/IP source are pre-auth blocked unless an early HMAC-backed lookup proves an active trusted-user-owned API key. Scheduler-specific read-write/Admin authorization remains the scheduler boundary, not an auto-ban decision. - The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The normal login submission rendered from that recovery page must also be able to reach authentication so a trusted Owner context can be established, but only when it carries the explicit recovery marker rendered with that form. Ordinary `POST /user/login` submissions remain subject to active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. -- Ban decisions follow the Security policy enforcement order so trusted-user context, trusted-user API-key context, active Admin/Owner session context, and recovery-login rendering are resolved before Visitor/IP bans can deny access, while active bans still run before error pages or rate-limit responses can be produced. +- Ban decisions follow the Security policy enforcement order so recovery-login rendering and marked recovery submissions remain reachable, while active bans still run before error pages, API auth success/failure responses, API preflight handling, or rate-limit responses can be produced. - `/api/live/**` stays outside ordinary rate-limit rejection, but it is not an active auto-ban bypass. Once a Visitor/IP source is actively banned, live JSON endpoints must receive the same bare `403` enforcement unless the trusted-user or recovery-login policy applies. - Config keys must be registered through the settings/default provider and setup seeder so setup, missing database, or unavailable database states read safe defaults without touching Doctrine/DBAL. The auto-ban enabled runtime fallback is disabled, while setup writes the completed-installation value as enabled. When the database is unavailable, signal persistence, score evaluation, Admin list/reset, and enforcement degrade fail-open. - Active temporary ban responses use the shared `HttpErrorRenderer` forced bare response path: `403 Forbidden`, `Retry-After` when TTL is known, `Cache-Control: no-store`, safe Request ID/reference, and a generic message. They must not expose score values, rule names, subject keys, Visitor IDs, IP buckets, raw IP data, paths, headers, signal internals, or ban history, and must not record additional Security signals for the auto-ban `403`. They still remain normal access-log entries so Owners can correlate Request ID, Visitor ID, status, and retained signal context during manual audits. @@ -75,13 +75,13 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - The active-ban list is backed by the active ban store's cache index, while detail/reason evidence is backed by retained Security signals. If the cache index cannot be updated atomically with newly created active state, the new active state must be rolled back so enforcement does not create an unreviewable ban. If the cache index is unavailable or inconsistent, enforcement must fail open and the Admin UI should show a safe degraded-state diagnostic rather than inferring active bans from stale historical signals alone. - Ban-state keys come only from the shared subject/client-identity resolver and are limited to source subjects such as Visitor ID and stable IP bucket/HMAC. Raw IP strings, raw forwarding headers, raw API keys, credentials, usernames, emails, session IDs, visitor-cookie material, user IDs, and API-key identifiers must never become active auto-ban keys. - IP-derived evaluation and Admin review remain within existing IP-retention ceilings. IP ban TTLs must not exceed the seven-day maximum TTL and must never extend queryable IP-derived evidence beyond retention. -- First implementation should use explicit subscriber priorities relative to existing security hooks: suspicious probe handling remains earliest, trusted-user/API-key context must be available before ordinary active-ban enforcement, and ordinary active-ban enforcement must run before `RateLimitRequestSubscriber::onKernelRequestOrdinary()` can consume buckets or return `429`. If a single priority cannot satisfy both browser-session and API-key context, split browser and API ban checks by request family while preserving this ordering. +- First implementation should use explicit subscriber priorities relative to existing security hooks: suspicious probe handling remains earliest, `/api/v1/**` and raw `/cron/run` active-ban checks run before normal API/scheduler authentication but may perform a narrow trusted-key lookup, trusted browser context remains available before ordinary active-ban enforcement, and ordinary active-ban enforcement must run before `RateLimitRequestSubscriber::onKernelRequestOrdinary()` can consume buckets or return `429`. If a single priority cannot satisfy both browser-session and API-key context, split browser, API, and scheduler ban checks by request family while preserving this ordering. ## Edge cases - Expired cache-flock bans must stop blocking even if cleanup is delayed. - Missing or stale active-ban index entries must not create enforcement decisions by themselves; the authoritative active block is the per-subject cache-flock TTL state. -- Trusted users at or above the configured minimum access level must not be banned by Visitor ID or IP source scoring; valid API keys owned by trusted users must bypass existing Visitor/IP bans under the trusted-user rule. +- Trusted users at or above the configured minimum access level must not be banned by Visitor ID or IP source scoring; valid active API keys owned by trusted users may bypass existing Visitor/IP bans through the early trusted-key lookup, while invalid, revoked, or below-trusted keys remain blocked. - Owner sessions and Owner recovery must remain available even when the current Visitor ID or IP bucket is actively banned. - Setup/install states may have no database and no Owner. Auto-ban must no-op/fail open in those states except for DB-free probe/error rendering already handled by earlier branches. - Shared IPs can be blocked only after the laxer IP threshold is crossed and should not prevent trusted users from using the recovery/login path. @@ -109,7 +109,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Test active-ban cache index list rendering, stale-index cleanup/degraded diagnostics, and that stale index entries do not block without active per-subject TTL state. - Test fail-open behavior when database, Config, cache, lock/flock, or signal storage is unavailable. - Test trusted registered users at and above the configured minimum access level are never auto-banned, with the default `MANAGER` level and Owner lockout protection covered. -- Test valid trusted-user-owned API keys bypass active Visitor/IP bans after API-key authentication resolves the trusted user context, while non-trusted or invalid API-key requests remain subject to Visitor/IP source enforcement. +- Test valid trusted-user-owned API keys bypass active Visitor/IP bans through the early trusted-key lookup, while non-trusted, revoked, unavailable, or invalid API-key requests remain subject to Visitor/IP source enforcement before API auth failure/success side effects. - Test subscriber ordering against existing probe, API authentication, browser session, ordinary rate-limit, and error-rendering hooks. - Test recovery-login bypass render despite active Visitor/IP bans, dedicated recovery-login bucket behavior, CSRF/credential/failure accounting, audit logging, and post-login re-evaluation. - Test `/api/live/**` remains outside ordinary rate-limit `429` handling but does not bypass an already active auto-ban. diff --git a/dev/draft/security-hardening/policy-defaults.md b/dev/draft/security-hardening/policy-defaults.md index e5b9ace8..eb99c74c 100644 --- a/dev/draft/security-hardening/policy-defaults.md +++ b/dev/draft/security-hardening/policy-defaults.md @@ -190,7 +190,7 @@ The codebase and other feature drafts expose several security-relevant surfaces - Auto-ban storage degradation is fail-open. If database, signal storage, Config, cache, lock/flock, 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. - If a scoreable signal is recorded but the subsequent score query or cache-flock ban creation fails, the request remains allowed or proceeds with the response already selected by the owning workflow. Auto-ban must not retry score aggregation synchronously on later unrelated non-signal requests. - Auto-ban Config keys must be registered through the settings/default provider so setup, missing database, or unavailable database states read safe defaults without touching Doctrine/DBAL. When the database is unavailable, signal persistence and score evaluation cannot happen, so the policy is fail-open. -- Trusted registered users are never auto-banned. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, this also protects Owners from self-lockout through auto-ban. Valid API keys owned by trusted users inherit this bypass because the trusted user context has been resolved before active ban enforcement. +- Trusted registered users are never selected as auto-ban subjects. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, this also protects Owners from being newly banned through trusted browser context. API keys are not active auto-ban subjects; `/api/v1/**` and raw `/cron/run` requests from an already banned Visitor/IP source are pre-auth blocked before invalid credentials, untrusted credentials, CORS preflights, authentication-failure signals, controller handling, or rate-limit buckets can pass, but an early HMAC-backed lookup may allow active trusted-user-owned API keys. Scheduler-specific read-write/Admin authorization remains the scheduler boundary after the ban guard. - Visitor IDs and IP buckets that resolve to a trusted registered user session or trusted-user-owned API key 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. diff --git a/src/Api/Security/ApiKeyAuthenticator.php b/src/Api/Security/ApiKeyAuthenticator.php index 3c02f067..d19859c2 100644 --- a/src/Api/Security/ApiKeyAuthenticator.php +++ b/src/Api/Security/ApiKeyAuthenticator.php @@ -8,10 +8,8 @@ use App\Core\Message\Message; use App\Entity\ApiKey; use App\Security\ApiKeyStatus; -use App\Security\ApiKeyVault; use App\Security\SecurityMessageCode; use App\Security\SecurityMessageKey; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -24,8 +22,7 @@ final class ApiKeyAuthenticator extends AbstractAuthenticator { public function __construct( - private readonly EntityManagerInterface $entityManager, - private readonly ApiKeyVault $apiKeyVault, + private readonly ApiKeyCredentialResolver $credentials, private readonly ApiSecurityHandler $securityHandler, private readonly ApiRequestMethodPolicy $methodPolicy = new ApiRequestMethodPolicy(), ) { @@ -34,18 +31,12 @@ public function __construct( public function supports(Request $request): ?bool { return $this->methodPolicy->isApiV1Request($request) - && $this->hasBearerAuthorizationScheme($request); + && $this->credentials->supportsBearer($request); } public function authenticate(Request $request): Passport { - $plainKey = $this->bearerToken($request); - - if (null === $plainKey) { - throw $this->authenticationFailed(); - } - - $apiKey = $this->apiKeyFor($plainKey); + $apiKey = $this->credentials->resolve($request); if (!$apiKey instanceof ApiKey) { throw $this->authenticationFailed(); @@ -77,55 +68,6 @@ public function onAuthenticationFailure(Request $request, AuthenticationExceptio return $this->securityHandler->authenticationFailure($request, $exception); } - private function bearerToken(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]); - - return '' !== $token && strlen($token) <= 512 ? $token : null; - } - - private function hasBearerAuthorizationScheme(Request $request): bool - { - $authorization = $request->headers->get('Authorization'); - - return is_string($authorization) && 1 === preg_match('/^Bearer(?:\s+|$)/i', $authorization); - } - - private function apiKeyFor(string $plainKey): ?ApiKey - { - $prefix = $this->prefix($plainKey); - - if (null === $prefix) { - return null; - } - - $apiKey = $this->entityManager->getRepository(ApiKey::class)->findOneBy([ - 'prefix' => $prefix, - 'hmacHash' => $this->apiKeyVault->hmac($plainKey), - ]); - - return $apiKey instanceof ApiKey ? $apiKey : null; - } - - private function prefix(string $plainKey): ?string - { - $dotPosition = strpos($plainKey, '.'); - - if (false === $dotPosition) { - return null; - } - - $prefix = substr($plainKey, 0, $dotPosition); - - return 1 === preg_match('/^[A-Za-z0-9_-]{4,16}$/', $prefix) ? $prefix : null; - } - private function authenticationFailed(): ApiAuthenticationException { return new ApiAuthenticationException(Message::warning( diff --git a/src/Api/Security/ApiKeyCredentialResolver.php b/src/Api/Security/ApiKeyCredentialResolver.php new file mode 100644 index 00000000..3a16c9a4 --- /dev/null +++ b/src/Api/Security/ApiKeyCredentialResolver.php @@ -0,0 +1,90 @@ +headers->get('Authorization'); + + return is_string($authorization) && 1 === preg_match('/^Bearer(?:\s+|$)/i', $authorization); + } + + public function resolve(Request $request): ?ApiKey + { + $plainKey = $this->bearerToken($request); + if (null === $plainKey) { + return null; + } + + $prefix = $this->prefix($plainKey); + if (null === $prefix) { + return null; + } + + $apiKey = $this->entityManager->getRepository(ApiKey::class)->findOneBy([ + 'prefix' => $prefix, + 'hmacHash' => $this->apiKeyVault->hmac($plainKey), + ]); + + return $apiKey instanceof ApiKey ? $apiKey : null; + } + + public function resolveBearerHmac(Request $request): ?ApiKey + { + $plainKey = $this->bearerToken($request); + if (null === $plainKey) { + return null; + } + + return $this->resolvePlainKeyHmac($plainKey); + } + + public function resolvePlainKeyHmac(string $plainKey): ?ApiKey + { + $apiKey = $this->entityManager->getRepository(ApiKey::class)->findOneBy([ + 'hmacHash' => $this->apiKeyVault->hmac($plainKey), + ]); + + return $apiKey instanceof ApiKey ? $apiKey : null; + } + + private function bearerToken(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]); + + return '' !== $token && strlen($token) <= 512 ? $token : null; + } + + private function prefix(string $plainKey): ?string + { + $dotPosition = strpos($plainKey, '.'); + if (false === $dotPosition) { + return null; + } + + $prefix = substr($plainKey, 0, $dotPosition); + + return 1 === preg_match('/^[A-Za-z0-9_-]{4,16}$/', $prefix) ? $prefix : null; + } +} diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index 08065cc0..b85997bd 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -34,6 +34,7 @@ { public const PASSIVE_SIGNAL_SKIP_ATTRIBUTE = '_system_auto_ban_response'; public const PROBE_RATE_LIMIT_SKIP_ATTRIBUTE = '_system_auto_ban_skip_probe_rate_limit'; + public const TRUSTED_PRE_AUTH_BYPASS_ATTRIBUTE = '_system_auto_ban_trusted_pre_auth_bypass'; public const RECOVERY_LOGIN_TOKEN_FIELD = '_auto_ban_recovery_token'; public const RECOVERY_LOGIN_TOKEN_ID = 'auto_ban_recovery_login'; @@ -52,6 +53,7 @@ public function __construct( ?SuspiciousProbePathMatcher $probePaths = null, ?RequestPathResolver $paths = null, private ?CsrfTokenManagerInterface $csrfTokens = null, + private ?TrustedApiKeyAutoBanBypass $trustedApiKeys = null, ) { $this->probePaths = $probePaths ?? new SuspiciousProbePathMatcher(); $this->paths = $paths ?? new RequestPathResolver(); @@ -61,6 +63,7 @@ public static function getSubscribedEvents(): array { return [ KernelEvents::REQUEST => [ + ['onKernelRequestPreAuthSourceBan', 4098], ['onKernelRequestProbeCandidate', 4097], ['onKernelRequestLogin', 16], ['onKernelRequest', 4], @@ -68,6 +71,42 @@ public static function getSubscribedEvents(): array ]; } + public function onKernelRequestPreAuthSourceBan(RequestEvent $event): void + { + $request = $event->getRequest(); + $protectedSurface = $this->preAuthProtectedSurface($request); + if (!$event->isMainRequest() || $event->hasResponse() || !$this->enabledForRequest($request) || !$this->policy->enabled() || null === $protectedSurface) { + return; + } + + try { + $ban = $this->activeBanFor($this->inspector->inspect($request)['subjects']); + if (!$ban instanceof ActiveAutoBan) { + return; + } + + if ($this->trustedApiKeys?->allows( + $request, + allowPrefixlessBearer: 'scheduler' === $protectedSurface, + allowSchedulerQuery: 'scheduler' === $protectedSurface, + )) { + $request->attributes->set(self::TRUSTED_PRE_AUTH_BYPASS_ATTRIBUTE, true); + + return; + } + + $event->setResponse($this->banResponse($request, $ban)); + $request->attributes->set(self::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + } catch (Throwable $error) { + $this->reportEvaluation($error, [ + 'operation' => 'api_source_ban', + 'path' => $request->getPathInfo(), + ]); + + return; + } + } + public function onKernelRequestProbeCandidate(RequestEvent $event): void { $request = $event->getRequest(); @@ -127,6 +166,10 @@ public function onKernelRequest(RequestEvent $event): void } $request = $event->getRequest(); + if ($request->attributes->getBoolean(self::TRUSTED_PRE_AUTH_BYPASS_ATTRIBUTE)) { + return; + } + try { $inspection = $this->inspector->inspect($request); if ($this->recoveryRequest($request, $inspection['profile']) || $this->trustedContext($inspection['subjects']->subjects())) { @@ -224,6 +267,17 @@ private function loginSubmissionCandidate(Request $request): bool return $this->paths->matchesExact($request, 'user', 'login'); } + private function preAuthProtectedSurface(Request $request): ?string + { + $segments = array_values(array_filter(explode('/', trim($request->getPathInfo(), '/')), static fn (string $segment): bool => '' !== $segment)); + + if ('api' === ($segments[0] ?? null) && 'v1' === ($segments[1] ?? null)) { + return 'api'; + } + + return ['cron', 'run'] === $segments ? 'scheduler' : null; + } + private function banResponse(Request $request, ActiveAutoBan $ban): Response { $retryAfter = $ban->retryAfterSeconds($this->clock->now()); diff --git a/src/Security/AutoBan/TrustedApiKeyAutoBanBypass.php b/src/Security/AutoBan/TrustedApiKeyAutoBanBypass.php new file mode 100644 index 00000000..e43e0b3c --- /dev/null +++ b/src/Security/AutoBan/TrustedApiKeyAutoBanBypass.php @@ -0,0 +1,50 @@ +credentials->resolveBearerHmac($request) + : $this->credentials->resolve($request); + + if (null === $apiKey && $allowSchedulerQuery && $this->schedulerSettings->getAuthEnabled()) { + $auth = $request->query->get('auth'); + $apiKey = is_string($auth) && '' !== trim($auth) + ? $this->credentials->resolvePlainKeyHmac(trim($auth)) + : null; + } + + return $this->trusted($apiKey); + } catch (Throwable) { + return false; + } + } + + private function trusted(?ApiKey $apiKey): bool + { + if (null === $apiKey || !$apiKey->status()->isActive() || !$apiKey->user()->status()->isUsable()) { + return false; + } + + return $apiKey->user()->accessLevel() >= $this->policy->trustedAccessLevel(); + } +} diff --git a/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php b/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php index 54973bf8..cb5b820b 100644 --- a/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php +++ b/src/Security/RateLimit/RateLimitAuthenticationSubscriber.php @@ -8,6 +8,7 @@ use App\Security\Abuse\AbuseRequestInspector; use App\Security\Abuse\AbuseSubject; use App\Security\Abuse\AbuseSubjectType; +use App\Security\AutoBan\AutoBanRequestSubscriber; use App\Security\Abuse\SecuritySignalRecorder; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Http\Event\LoginFailureEvent; @@ -46,6 +47,10 @@ public function onLoginSuccess(LoginSuccessEvent $event): void public function onLoginFailure(LoginFailureEvent $event): void { $request = $event->getRequest(); + if ($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)) { + return; + } + $this->recordAuthFailure($event); if (!$this->enabledForRequest($request->headers->get('X-Rate-Limit-Testing'))) { diff --git a/tests/Controller/RateLimitEnforcementControllerTest.php b/tests/Controller/RateLimitEnforcementControllerTest.php index cc7c07af..add2af38 100644 --- a/tests/Controller/RateLimitEnforcementControllerTest.php +++ b/tests/Controller/RateLimitEnforcementControllerTest.php @@ -9,6 +9,12 @@ use App\Core\Config\ConfigValueType; use App\Entity\ApiKey; use App\Entity\UserAccount; +use App\Security\Abuse\AbuseRequestInspector; +use App\Security\Abuse\AbuseSubject; +use App\Security\Abuse\AbuseSubjectType; +use App\Security\AutoBan\AutoBanPolicy; +use App\Security\AutoBan\AutoBanStore; +use App\Security\AutoBan\AutoBanSubject; use App\Security\ApiKeyStatus; use App\Security\ApiKeyVault; use App\Security\RateLimit\RateLimitPolicyCatalogue; @@ -16,6 +22,7 @@ use Doctrine\ORM\EntityManagerInterface; use Psr\Cache\CacheItemPoolInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\HttpFoundation\Request; final class RateLimitEnforcementControllerTest extends WebTestCase { @@ -192,6 +199,54 @@ public function testInvalidBearerOptionsRequestsSpendApiBudgetBeforeAuthenticati self::assertResponseStatusCodeSame(429); } + public function testActiveAutoBanPreemptsApiAuthenticationAndPreflights(): void + { + $client = self::createClient(); + $cache = self::getContainer()->get('cache.app'); + self::assertInstanceOf(CacheItemPoolInterface::class, $cache); + $cache->clear(); + + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set(AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, true, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, ['https://client.example'], ConfigValueType::Json); + $plainKey = $this->createOwnerApiKey('banapi'); + + try { + $this->banIpBucketFor('/api/v1/status', '198.51.100.31'); + $client->request('GET', '/api/v1/status', server: $this->server('198.51.100.31') + [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid.invalid-secret', + 'HTTP_X_AUTO_BAN_TESTING' => '1', + ]); + self::assertResponseStatusCodeSame(403); + self::assertSame('3600', $client->getResponse()->headers->get('Retry-After')); + self::assertStringNotContainsString('api_key.authentication_failed', $client->getResponse()->getContent()); + + $this->banIpBucketFor('/api/v1/status', '198.51.100.32'); + $client->request('GET', '/api/v1/status', server: $this->server('198.51.100.32') + [ + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'HTTP_X_AUTO_BAN_TESTING' => '1', + ]); + self::assertResponseIsSuccessful(); + self::assertStringContainsString('api_status', $client->getResponse()->getContent()); + + $this->banIpBucketFor('/api/v1/admin/settings/general', '198.51.100.33'); + $client->request('OPTIONS', '/api/v1/admin/settings/general', server: $this->server('198.51.100.33') + [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer '.$plainKey, + 'HTTP_ORIGIN' => 'https://client.example', + 'HTTP_X_AUTO_BAN_TESTING' => '1', + ]); + self::assertNotSame(403, $client->getResponse()->getStatusCode()); + self::assertSame('https://client.example', $client->getResponse()->headers->get('Access-Control-Allow-Origin')); + } finally { + $this->removeApiKey('banapi'); + $config->set(ApiFeaturePolicy::CORS_ENABLED_KEY, false, ConfigValueType::Boolean); + $config->set(ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY, [], ConfigValueType::Json); + } + } + public function testCorsBearerPreflightsSpendAuthFailureBudgetBeforeCorsShortCircuit(): void { $client = self::createClient(server: $this->server('198.51.100.28')); @@ -486,6 +541,23 @@ private function setMode(RateLimitProfile $profile): void $config->set(RateLimitPolicyCatalogue::MODE_KEY, $profile->value, ConfigValueType::String, modifiedBy: 'test'); } + private function banIpBucketFor(string $path, string $ip): void + { + $request = Request::create($path, server: $this->server($ip)); + $inspector = self::getContainer()->get(AbuseRequestInspector::class); + self::assertInstanceOf(AbuseRequestInspector::class, $inspector); + + $subject = $inspector->inspect($request)['subjects']->first(AbuseSubjectType::IpBucket); + self::assertInstanceOf(AbuseSubject::class, $subject); + + $autoBanSubject = AutoBanSubject::fromAbuseSubject($subject); + self::assertInstanceOf(AutoBanSubject::class, $autoBanSubject); + + $store = self::getContainer()->get(AutoBanStore::class); + self::assertInstanceOf(AutoBanStore::class, $store); + $store->ban($autoBanSubject, 3600); + } + private function adminUser(): UserAccount { $entityManager = self::getContainer()->get(EntityManagerInterface::class); diff --git a/tests/Controller/SchedulerControllerTest.php b/tests/Controller/SchedulerControllerTest.php index a40111d4..1cab8867 100644 --- a/tests/Controller/SchedulerControllerTest.php +++ b/tests/Controller/SchedulerControllerTest.php @@ -7,11 +7,20 @@ use App\Core\Config\Config; use App\Core\Config\ConfigValueType; use App\Entity\SchedulerTask; +use App\Security\Abuse\AbuseRequestInspector; +use App\Security\Abuse\AbuseSubject; +use App\Security\Abuse\AbuseSubjectType; +use App\Security\AutoBan\AutoBanPolicy; +use App\Security\AutoBan\AutoBanStore; +use App\Security\AutoBan\AutoBanSubject; +use App\Security\AutoBan\TrustedApiKeyAutoBanBypass; use App\Scheduler\SchedulerLockFactory; use App\Scheduler\SchedulerSettings; use App\Scheduler\SchedulerTaskDefinition; use Doctrine\ORM\EntityManagerInterface; +use Psr\Cache\CacheItemPoolInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\HttpFoundation\Request; final class SchedulerControllerTest extends WebTestCase { @@ -107,6 +116,52 @@ public function testCronRunRejectsOversizedApiKeyWithoutReflectingIt(): void self::assertSame(str_repeat('a', 16).'…', json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR)['auth']); } + public function testCronRunAutoBanGuardAllowsOnlyTrustedKeysBeforeSchedulerAuth(): void + { + $client = self::createClient(); + $cache = self::getContainer()->get('cache.app'); + self::assertInstanceOf(CacheItemPoolInterface::class, $cache); + $cache->clear(); + + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + $config->set(AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED, ConfigValueType::Boolean); + + try { + $this->banIpBucketFor('/cron/run', '198.51.100.41'); + $client->request('GET', '/cron/run', server: $this->server('198.51.100.41') + [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid-scheduler-key', + ]); + self::assertResponseStatusCodeSame(403); + + $this->banIpBucketFor('/cron/run', '198.51.100.42'); + $trustedBypass = self::getContainer()->get(TrustedApiKeyAutoBanBypass::class); + self::assertInstanceOf(TrustedApiKeyAutoBanBypass::class, $trustedBypass); + self::assertTrue($trustedBypass->allows( + Request::create('/cron/run', server: ['HTTP_AUTHORIZATION' => 'Bearer test_seed_read_write_key']), + allowPrefixlessBearer: true, + allowSchedulerQuery: true, + )); + $client->request('GET', '/cron/run', server: $this->server('198.51.100.42') + [ + 'HTTP_AUTHORIZATION' => 'Bearer test_seed_read_write_key', + ]); + self::assertResponseIsSuccessful(); + + $this->banIpBucketFor('/cron/run', '198.51.100.43'); + $client->request('GET', '/cron/run', server: $this->server('198.51.100.43') + [ + 'HTTP_AUTHORIZATION' => 'Bearer test_seed_read_only_key', + ]); + self::assertResponseStatusCodeSame(401); + + $config->set(SchedulerSettings::GET_AUTH_ENABLED_KEY, true, ConfigValueType::Boolean); + $this->banIpBucketFor('/cron/run?auth=test_seed_read_write_key', '198.51.100.44'); + $client->request('GET', '/cron/run?auth=test_seed_read_write_key', server: $this->server('198.51.100.44')); + self::assertResponseIsSuccessful(); + } finally { + $config->set(SchedulerSettings::GET_AUTH_ENABLED_KEY, false, ConfigValueType::Boolean); + } + } + public function testCronRunGetAuthFallbackIsDisabledByDefault(): void { $client = self::createClient(); @@ -221,4 +276,33 @@ private function removeSchedulerTask(EntityManagerInterface $entityManager, stri $connection->delete('scheduler_task_run', ['task_identifier' => $identifier]); $connection->delete('scheduler_task', ['identifier' => $identifier]); } + + /** + * @return array + */ + private function server(string $ip): array + { + return [ + 'REMOTE_ADDR' => $ip, + 'HTTP_USER_AGENT' => 'SchedulerControllerTest', + 'HTTP_X_AUTO_BAN_TESTING' => '1', + ]; + } + + private function banIpBucketFor(string $path, string $ip): void + { + $request = Request::create($path, server: $this->server($ip)); + $inspector = self::getContainer()->get(AbuseRequestInspector::class); + self::assertInstanceOf(AbuseRequestInspector::class, $inspector); + + $subject = $inspector->inspect($request)['subjects']->first(AbuseSubjectType::IpBucket); + self::assertInstanceOf(AbuseSubject::class, $subject); + + $autoBanSubject = AutoBanSubject::fromAbuseSubject($subject); + self::assertInstanceOf(AutoBanSubject::class, $autoBanSubject); + + $store = self::getContainer()->get(AutoBanStore::class); + self::assertInstanceOf(AutoBanStore::class, $store); + $store->ban($autoBanSubject, 3600); + } } diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index f83959cc..a40c0f1b 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -116,6 +116,66 @@ public function testIgnorablePathsDoNotBypassActiveBansWhenTheyReachSymfony(): v self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); } + public function testApiRequestsWithoutTrustedBearerDoNotAuthenticateThroughActiveBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid.invalid-secret', + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthSourceBan($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertSame('3600', $event->getResponse()?->headers->get('Retry-After')); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testApiPreflightsDoNotBypassActiveBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer valid-owner-key', + 'HTTP_ORIGIN' => 'https://client.example', + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthSourceBan($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testSchedulerTriggersWithoutTrustedKeyDoNotBypassActiveBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/cron/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid-scheduler-key', + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthSourceBan($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + public function testRecoveryLoginBypassIsReachableDespiteActiveBan(): void { $clock = new MockClock('2026-06-18 12:00:00'); @@ -280,12 +340,14 @@ public function testSubscriberRunsAfterSecurityContextButBeforeOrdinaryRateLimit $autoBan = AutoBanRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; $rateLimit = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; - self::assertSame(['onKernelRequestProbeCandidate', 4097], $autoBan[0]); - self::assertSame(['onKernelRequestLogin', 16], $autoBan[1]); - self::assertSame(['onKernelRequest', 4], $autoBan[2]); + self::assertSame(['onKernelRequestPreAuthSourceBan', 4098], $autoBan[0]); + self::assertSame(['onKernelRequestProbeCandidate', 4097], $autoBan[1]); + self::assertSame(['onKernelRequestLogin', 16], $autoBan[2]); + self::assertSame(['onKernelRequest', 4], $autoBan[3]); self::assertGreaterThan($rateLimit[0][1], $autoBan[0][1]); + self::assertGreaterThan($rateLimit[0][1], $autoBan[1][1]); self::assertSame(['onKernelRequestOrdinary', 3], $rateLimit[1]); - self::assertGreaterThan($rateLimit[1][1], $autoBan[2][1]); + self::assertGreaterThan($rateLimit[1][1], $autoBan[3][1]); } private function subscriber( diff --git a/tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php b/tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php new file mode 100644 index 00000000..ca94debe --- /dev/null +++ b/tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php @@ -0,0 +1,75 @@ + 'Bearer invalid.invalid', + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + $event = new LoginFailureEvent( + new AuthenticationException('Invalid credentials.'), + new RateLimitAuthenticationTestAuthenticator(), + $request, + null, + 'api', + ); + + (new RateLimitAuthenticationSubscriber( + (new \ReflectionClass(RateLimitResetService::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor(), + 'prod', + ))->onLoginFailure($event); + + self::assertNull($event->getResponse()); + } +} + +final class RateLimitAuthenticationTestAuthenticator implements AuthenticatorInterface +{ + public function supports(Request $request): ?bool + { + return true; + } + + public function authenticate(Request $request): Passport + { + throw new AuthenticationException('Not used by this test.'); + } + + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + throw new AuthenticationException('Not used by this test.'); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return null; + } +} From ec7a04e96bc3202e37432f26d19311b54e3857af Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 18:07:13 +0200 Subject: [PATCH 35/55] Harden auto-ban audit follow-ups --- dev/WORKLOG.md | 1 + src/Core/Log/AccessLogSubscriber.php | 10 +++++--- src/Core/Log/AccessRequestMetadata.php | 1 + .../AutoBan/AutoBanRequestSubscriber.php | 1 + .../AutoBan/TrustedApiKeyAutoBanBypass.php | 14 +++++++++-- tests/Controller/SchedulerControllerTest.php | 5 ++++ tests/Core/Log/AccessLogSubscriberTest.php | 25 +++++++++++++++++++ .../AutoBan/AutoBanRequestSubscriberTest.php | 1 + 8 files changed, 52 insertions(+), 6 deletions(-) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 8c3c42cf..18721911 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -107,6 +107,7 @@ - Addressed fourth Cloud Review round with separate reviewable commits: active auto-ban checks no longer skip shared ignorable/static paths when those requests reach Symfony, probe candidates mark already-banned sources before suspicious-probe rate-limit consumption, ordinary login submissions are blocked before form authentication unless they carry the recovery marker, and manual reset now releases active state before recording the reset cutoff while restoring active state best-effort if the cutoff cannot be persisted. - Addressed the fifth Cloud Review reset race by moving reset release plus cutoff recording under the same subject-key lock used for ban creation, so concurrent score evaluation cannot recreate an active ban from pre-reset evidence between cache release and reset-signal persistence. - Addressed the fifth Cloud Review API-auth ordering issue by adding an early `/api/v1/**` and raw `/cron/run` active-ban guard that blocks invalid/untrusted credentials and credentialed preflights before auth failure/success side effects, while a shared HMAC-backed credential resolver allows active trusted-user-owned API keys, including scheduler `?auth=` when enabled, to bypass the source ban under the trusted-user rule before downstream API/scheduler authorization runs. +- Follow-up audit over prior review fixes found and closed two adjacent edges: auto-ban bare `403` responses now force access-log recording even on otherwise ignorable paths that still reach Symfony, and the pre-auth scheduler trusted-key bypass mirrors the scheduler `?auth=` token length/control-character guard before HMAC/DB lookup. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/src/Core/Log/AccessLogSubscriber.php b/src/Core/Log/AccessLogSubscriber.php index abba055d..e52dcad0 100644 --- a/src/Core/Log/AccessLogSubscriber.php +++ b/src/Core/Log/AccessLogSubscriber.php @@ -13,6 +13,7 @@ use App\Core\Statistics\VisitorIdGenerator; use App\Database\DatabaseReadyState; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; @@ -44,7 +45,7 @@ public static function getSubscribedEvents(): array public function onKernelRequest(RequestEvent $event): void { - if (!$event->isMainRequest() || $this->shouldSkipAccessLog($event->getRequest()->getPathInfo())) { + if (!$event->isMainRequest() || $this->shouldSkipAccessLog($event->getRequest())) { return; } @@ -53,7 +54,7 @@ public function onKernelRequest(RequestEvent $event): void public function onKernelResponse(ResponseEvent $event): void { - if (!$event->isMainRequest() || $this->shouldSkipAccessLog($event->getRequest()->getPathInfo())) { + if (!$event->isMainRequest() || $this->shouldSkipAccessLog($event->getRequest())) { return; } @@ -74,9 +75,10 @@ public function onKernelResponse(ResponseEvent $event): void } } - private function shouldSkipAccessLog(string $path): bool + private function shouldSkipAccessLog(Request $request): bool { - return $this->ignorablePaths->matches($path); + return !$request->attributes->getBoolean(AccessRequestMetadata::FORCE_ACCESS_LOG_ATTRIBUTE) + && $this->ignorablePaths->matches($request->getPathInfo()); } private function shouldSkipStatistics(string $path): bool diff --git a/src/Core/Log/AccessRequestMetadata.php b/src/Core/Log/AccessRequestMetadata.php index 61a46ba7..1fe21fba 100644 --- a/src/Core/Log/AccessRequestMetadata.php +++ b/src/Core/Log/AccessRequestMetadata.php @@ -14,6 +14,7 @@ public const REQUEST_ID_ATTRIBUTE = '_access_request_id'; public const CORRELATION_ID_ATTRIBUTE = '_access_correlation_id'; public const STARTED_AT_ATTRIBUTE = '_access_started_at'; + public const FORCE_ACCESS_LOG_ATTRIBUTE = '_access_force_log'; private const GENERATED_REQUEST_ID_BYTES = 12; private const MAX_REQUEST_ID_LENGTH = 64; private const MIN_REQUEST_ID_LENGTH = 8; diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index b85997bd..bd7a93ad 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -281,6 +281,7 @@ private function preAuthProtectedSurface(Request $request): ?string private function banResponse(Request $request, ActiveAutoBan $ban): Response { $retryAfter = $ban->retryAfterSeconds($this->clock->now()); + $request->attributes->set(AccessRequestMetadata::FORCE_ACCESS_LOG_ATTRIBUTE, true); return $this->httpError->bare(Response::HTTP_FORBIDDEN, $request, [ 'request_id' => $this->requestMetadata->requestId($request), diff --git a/src/Security/AutoBan/TrustedApiKeyAutoBanBypass.php b/src/Security/AutoBan/TrustedApiKeyAutoBanBypass.php index e43e0b3c..bfef9579 100644 --- a/src/Security/AutoBan/TrustedApiKeyAutoBanBypass.php +++ b/src/Security/AutoBan/TrustedApiKeyAutoBanBypass.php @@ -12,6 +12,8 @@ final readonly class TrustedApiKeyAutoBanBypass { + private const MAX_SCHEDULER_QUERY_TOKEN_LENGTH = 128; + public function __construct( private ApiKeyCredentialResolver $credentials, private AutoBanPolicy $policy, @@ -28,8 +30,9 @@ public function allows(Request $request, bool $allowPrefixlessBearer = false, bo if (null === $apiKey && $allowSchedulerQuery && $this->schedulerSettings->getAuthEnabled()) { $auth = $request->query->get('auth'); - $apiKey = is_string($auth) && '' !== trim($auth) - ? $this->credentials->resolvePlainKeyHmac(trim($auth)) + $token = is_string($auth) ? trim($auth) : ''; + $apiKey = $this->acceptableSchedulerQueryToken($token) + ? $this->credentials->resolvePlainKeyHmac($token) : null; } @@ -47,4 +50,11 @@ private function trusted(?ApiKey $apiKey): bool return $apiKey->user()->accessLevel() >= $this->policy->trustedAccessLevel(); } + + private function acceptableSchedulerQueryToken(string $token): bool + { + return '' !== $token + && strlen($token) <= self::MAX_SCHEDULER_QUERY_TOKEN_LENGTH + && 1 === preg_match('/^[^\s\x00-\x1F\x7F]+$/', $token); + } } diff --git a/tests/Controller/SchedulerControllerTest.php b/tests/Controller/SchedulerControllerTest.php index 1cab8867..0491adbe 100644 --- a/tests/Controller/SchedulerControllerTest.php +++ b/tests/Controller/SchedulerControllerTest.php @@ -157,6 +157,11 @@ public function testCronRunAutoBanGuardAllowsOnlyTrustedKeysBeforeSchedulerAuth( $this->banIpBucketFor('/cron/run?auth=test_seed_read_write_key', '198.51.100.44'); $client->request('GET', '/cron/run?auth=test_seed_read_write_key', server: $this->server('198.51.100.44')); self::assertResponseIsSuccessful(); + + $oversizedToken = str_repeat('a', 129); + $this->banIpBucketFor('/cron/run?auth='.$oversizedToken, '198.51.100.45'); + $client->request('GET', '/cron/run?auth='.$oversizedToken, server: $this->server('198.51.100.45')); + self::assertResponseStatusCodeSame(403); } finally { $config->set(SchedulerSettings::GET_AUTH_ENABLED_KEY, false, ConfigValueType::Boolean); } diff --git a/tests/Core/Log/AccessLogSubscriberTest.php b/tests/Core/Log/AccessLogSubscriberTest.php index 62ea160e..94c4c537 100644 --- a/tests/Core/Log/AccessLogSubscriberTest.php +++ b/tests/Core/Log/AccessLogSubscriberTest.php @@ -101,6 +101,31 @@ public function testItLogsAutoBanForbiddenResponsesForAuditCorrelation(): void ['path' => '/missing', 'status' => Response::HTTP_FORBIDDEN], ], $statisticsRecorder->records); } + + public function testItLogsAutoBanForbiddenResponsesForIgnorablePaths(): void + { + $accessLogger = new RecordingAccessLogger(); + $statisticsRecorder = new RecordingAccessStatisticsRecorder(); + $request = Request::create('/favicon.ico', server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + $request->attributes->set(AccessRequestMetadata::FORCE_ACCESS_LOG_ATTRIBUTE, true); + $response = new Response('blocked', Response::HTTP_FORBIDDEN); + + (new AccessLogSubscriber( + $accessLogger, + $statisticsRecorder, + new AccessRequestMetadata(), + new VisitorIdGenerator('test-secret'), + ))->onKernelResponse(new ResponseEvent( + new AccessSubscriberTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response, + )); + + self::assertSame(['/favicon.ico'], $accessLogger->paths); + self::assertSame([], $statisticsRecorder->records); + } } final class RecordingAccessLogger implements AccessLoggerInterface diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index a40c0f1b..e5a0810b 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -64,6 +64,7 @@ public function testActiveVisitorBanReturnsBareForbiddenBeforeApplicationHandlin self::assertStringContainsString('Request blocked due to suspicious activity.', (string) $response->getContent()); self::assertStringContainsString('request-ban', (string) $response->getContent()); self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + self::assertTrue($request->attributes->getBoolean(AccessRequestMetadata::FORCE_ACCESS_LOG_ATTRIBUTE)); } public function testActiveVisitorBanOverridesEarlierProbeResponseAndSkipsPassiveSignals(): void From 8e8a661fd0d14821fa129d5eaece124fc16fc75d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 18:48:39 +0200 Subject: [PATCH 36/55] Update pull request template --- .github/PULL_REQUEST_TEMPLATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 44d741ed..2094f5e0 100755 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -19,6 +19,7 @@ - [ ] Package/module boundaries, access levels, route/API/live endpoint scopes, and collision risks reviewed - [ ] Setup/init/CI, cross-platform behavior, disabled-feature fallbacks, and process/env handling reviewed - [ ] Project-rules-, architecture-, naming- and documentation-drift reviewed (see #57 for details) +- [ ] Codebase readability, naming, hierarchy, class map, frontend structure, and test-suite clarity reviewed (see #109 for details) - [ ] Follow-up tasks captured in WORKLOG - [ ] Updated / aligned translations and user-facing copy From 33a71f1a85fa7cf2a6eaa8e4cacee593c9af6e4a Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 18:58:21 +0200 Subject: [PATCH 37/55] Count auto-ban floor by request --- .../AutoBan/AutoBanSignalEvaluator.php | 26 +++++++++++++--- .../AutoBan/AutoBanSignalEvaluatorTest.php | 30 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/Security/AutoBan/AutoBanSignalEvaluator.php b/src/Security/AutoBan/AutoBanSignalEvaluator.php index 46a3208c..217dc67a 100644 --- a/src/Security/AutoBan/AutoBanSignalEvaluator.php +++ b/src/Security/AutoBan/AutoBanSignalEvaluator.php @@ -131,7 +131,7 @@ public function scoreSummary(AutoBanSubject $subject): array } $rows = $this->connection->fetchAllAssociative( - 'SELECT signal_type, reason_code, http_status FROM '.self::TABLE.' WHERE subject_type = ? AND subject_identifier = ? AND occurred_at > ? AND occurred_at <= ? AND expires_at > ? ORDER BY occurred_at ASC', + 'SELECT uid, signal_type, reason_code, request_id, http_status FROM '.self::TABLE.' WHERE subject_type = ? AND subject_identifier = ? AND occurred_at > ? AND occurred_at <= ? AND expires_at > ? ORDER BY occurred_at ASC', [ $subject->type(), $subject->identifier(), @@ -142,7 +142,7 @@ public function scoreSummary(AutoBanSubject $subject): array ); $score = 0; - $count = 0; + $qualifyingRequests = []; foreach ($rows as $row) { $weight = $this->scores->scoreFor( (string) ($row['signal_type'] ?? ''), @@ -154,10 +154,28 @@ public function scoreSummary(AutoBanSubject $subject): array } $score += $weight; - ++$count; + $qualifyingRequests[$this->requestFloorKey($row)] = true; } - return ['score' => $score, 'signal_count' => $count]; + return ['score' => $score, 'signal_count' => count($qualifyingRequests)]; + } + + /** + * @param array $row + */ + private function requestFloorKey(array $row): string + { + $requestId = trim((string) ($row['request_id'] ?? '')); + if ('' !== $requestId && 'n/a' !== $requestId) { + return 'request:'.$requestId; + } + + $uid = trim((string) ($row['uid'] ?? '')); + if ('' !== $uid) { + return 'signal:'.$uid; + } + + return 'signal:'.hash('sha256', json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: ''); } private function priorBanSignalCount(AutoBanSubject $subject): int diff --git a/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php b/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php index da064ca9..eca62345 100644 --- a/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php +++ b/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php @@ -40,6 +40,21 @@ public function testFirstQualifyingSignalDoesNotCreateBanEvenWhenScoreReachesThr self::assertNotNull($store->active($visitor)); } + public function testQualifyingFloorCountsDistinctRequestsInsteadOfSignalRows(): void + { + [$recorder, $store] = $this->stack(); + $visitor = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-same-request'); + + $this->recordPayloadProbe($recorder, $visitor, 'request-1'); + $this->recordError($recorder, $visitor, 'request-1'); + + self::assertNull($store->active($visitor)); + + $this->recordError($recorder, $visitor, 'request-2'); + + self::assertNotNull($store->active($visitor)); + } + public function testIpSubjectUsesLaxerThresholdMultiplier(): void { [$recorder, $store] = $this->stack(); @@ -154,6 +169,21 @@ private function recordError(SecuritySignalRecorder $recorder, AutoBanSubject $s ); } + private function recordPayloadProbe(SecuritySignalRecorder $recorder, AutoBanSubject $subject, string $requestId): void + { + $recorder->record( + 'payload_probe', + AutoBanScoreCatalogue::SIGNAL_SUSPICIOUS_PAYLOAD, + $subject->type(), + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: 'WARNING', + confidence: 90, + requestId: $requestId, + visitorId: 'visitor-context', + ); + } + public function testInvalidActiveBanPayloadFailsOpenWithMessage(): void { $clock = new MockClock('2026-06-18 12:00:00'); From 0040fc1c2640536b837abfdbebab5977d8ce4db9 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 18:59:31 +0200 Subject: [PATCH 38/55] Skip trusted scheduler source scoring --- .../Abuse/PassiveAbuseSignalSubscriber.php | 13 ++++++++ tests/Controller/SchedulerControllerTest.php | 30 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/Security/Abuse/PassiveAbuseSignalSubscriber.php b/src/Security/Abuse/PassiveAbuseSignalSubscriber.php index 0557447a..97bea292 100644 --- a/src/Security/Abuse/PassiveAbuseSignalSubscriber.php +++ b/src/Security/Abuse/PassiveAbuseSignalSubscriber.php @@ -9,7 +9,9 @@ use App\Core\Routing\IgnorableRequestPathMatcher; use App\Security\AutoBan\AutoBanPolicy; use App\Security\AutoBan\AutoBanRequestSubscriber; +use App\Security\AutoBan\TrustedApiKeyAutoBanBypass; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; use Throwable; @@ -24,6 +26,7 @@ public function __construct( private AccessRequestMetadata $accessRequestMetadata, private ?AutoBanPolicy $autoBanPolicy = null, ?IgnorableRequestPathMatcher $ignorablePaths = null, + private ?TrustedApiKeyAutoBanBypass $trustedApiKeys = null, ) { $this->ignorablePaths = $ignorablePaths ?? new IgnorableRequestPathMatcher(); } @@ -50,6 +53,10 @@ public function onKernelResponse(ResponseEvent $event): void return; } + if ($signal['source_scored'] && $this->trustedSchedulerCredential($event->getRequest(), $profile)) { + return; + } + $subjects = $inspection['subjects']; $sourceSubjects = $this->sourceSubjects($subjects, $signal['source_scored']); if ([] === $sourceSubjects) { @@ -162,6 +169,12 @@ private function trustedContext(AbuseSubjectResolution $subjects): bool return is_numeric($level) && (int) $level >= ($this->autoBanPolicy?->trustedAccessLevel() ?? AccessLevel::MANAGER); } + private function trustedSchedulerCredential(Request $request, AbuseRequestProfile $profile): bool + { + return RequestIntent::SchedulerTrigger === $profile->intent() + && true === $this->trustedApiKeys?->allows($request, allowPrefixlessBearer: true, allowSchedulerQuery: true); + } + private function shouldSkip(string $path): bool { return $this->ignorablePaths->matches($path); diff --git a/tests/Controller/SchedulerControllerTest.php b/tests/Controller/SchedulerControllerTest.php index 0491adbe..f67436b8 100644 --- a/tests/Controller/SchedulerControllerTest.php +++ b/tests/Controller/SchedulerControllerTest.php @@ -84,6 +84,36 @@ public function testCronRunRejectsUnknownJobIdentifier(): void self::assertResponseStatusCodeSame(404); } + public function testTrustedCronRunErrorsDoNotCreateSourceScoredSignals(): void + { + $client = self::createClient(); + $connection = self::getContainer()->get(EntityManagerInterface::class)->getConnection(); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + + $connection->executeStatement('DELETE FROM security_signal_event'); + $client->request('GET', '/cron/run?job=system.missing', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer test_seed_read_write_key', + 'REMOTE_ADDR' => '198.51.100.61', + ]); + + self::assertResponseStatusCodeSame(404); + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + + $config->set(SchedulerSettings::GET_AUTH_ENABLED_KEY, true, ConfigValueType::Boolean); + try { + $connection->executeStatement('DELETE FROM security_signal_event'); + $client->request('GET', '/cron/run?auth=test_seed_read_write_key&job=system.missing', server: [ + 'REMOTE_ADDR' => '198.51.100.62', + ]); + + self::assertResponseStatusCodeSame(404); + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } finally { + $config->set(SchedulerSettings::GET_AUTH_ENABLED_KEY, false, ConfigValueType::Boolean); + } + } + public function testCronRunRejectsMalformedJobIdentifierBeforeRegistryLookup(): void { $client = self::createClient(); From cd11ee581a19b820446e55237f5fdbeccf36087b Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 19:00:52 +0200 Subject: [PATCH 39/55] Validate auto-ban signal retention --- .../Config/Settings/CoreSettingsRegistry.php | 2 +- src/Security/AutoBan/AutoBanPolicy.php | 1 + .../Config/CoreSettingsFormHandlerTest.php | 30 +++++++++++++++++++ .../Core/Config/CoreSettingsRegistryTest.php | 1 + 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index 481b9dec..80d7bb4c 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -129,7 +129,7 @@ public function allDefinitions(): array ], 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], metadata: [ + 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' => AutoBanPolicy::MAX_TTL_DAYS, '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: [ diff --git a/src/Security/AutoBan/AutoBanPolicy.php b/src/Security/AutoBan/AutoBanPolicy.php index 802631d3..7e7be533 100644 --- a/src/Security/AutoBan/AutoBanPolicy.php +++ b/src/Security/AutoBan/AutoBanPolicy.php @@ -25,6 +25,7 @@ /** @var list */ public const TTL_ESCALATION_SECONDS = [3600, 10800, 86400, 604800]; + public const MAX_TTL_DAYS = 7; public function __construct(private Config $config) { diff --git a/tests/Core/Config/CoreSettingsFormHandlerTest.php b/tests/Core/Config/CoreSettingsFormHandlerTest.php index d1fa65e4..6e1fc7e6 100644 --- a/tests/Core/Config/CoreSettingsFormHandlerTest.php +++ b/tests/Core/Config/CoreSettingsFormHandlerTest.php @@ -14,6 +14,7 @@ use App\Core\Log\ConfigAuditLogPolicy; use App\Core\Log\DatabaseLogRetentionPolicy; use App\Form\FormSubmissionHandler; +use App\Form\FormErrorKey; use App\Localization\TranslationLanguageCatalog; use App\Security\Abuse\SuspiciousProbePathMatcher; use App\Security\AutoBan\AutoBanPolicy; @@ -142,6 +143,35 @@ public function testItRejectsInvalidRateLimitModes(): void self::assertNull($config->get(RateLimitPolicyCatalogue::MODE_KEY)); } + public function testItRejectsSecuritySignalRetentionBelowMaximumAutoBanTtl(): 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 => RateLimitProfile::Strict->value, + AutoBanPolicy::ENABLED_KEY => '1', + AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY => (string) AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, + AutoBanPolicy::SCORE_THRESHOLD_KEY => (string) AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, + AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY => '1', + ConfigAuditLogPolicy::ENABLED_KEY => '1', + ConfigAuditLogPolicy::EVENTS_KEY => ConfigAuditLogPolicy::DEFAULT_CATEGORIES, + DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY => (string) (AutoBanPolicy::MAX_TTL_DAYS - 1), + SuspiciousProbePathMatcher::PATTERNS_KEY => SuspiciousProbePathMatcher::defaultPatternText(), + ], 'test'); + + self::assertFalse($result->isValid()); + self::assertSame([FormErrorKey::MIN], $result->errors()[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY]); + self::assertNull($config->get(DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY)); + } + private function registry(): CoreSettingsRegistry { $projectDir = dirname(__DIR__, 3); diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index fc6ca5ad..8380f3c4 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -93,6 +93,7 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void self::assertSame(FormInputType::MultiSelect, $security[9]->formField()->inputType()); self::assertSame(ConfigAuditLogPolicy::DEFAULT_CATEGORIES, $security[9]->defaultValue()); self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $security[10]->defaultValue()); + self::assertSame(['min' => AutoBanPolicy::MAX_TTL_DAYS, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], $security[10]->formField()->validation()); self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $security[11]->defaultValue()); self::assertSame(FormInputType::Textarea, $security[11]->formField()->inputType()); From fc1cee97a48b3e7add6a537153e2dcd855d70140 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 19:01:10 +0200 Subject: [PATCH 40/55] Document auto-ban review fixes --- dev/WORKLOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 18721911..f3209948 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -108,6 +108,7 @@ - Addressed the fifth Cloud Review reset race by moving reset release plus cutoff recording under the same subject-key lock used for ban creation, so concurrent score evaluation cannot recreate an active ban from pre-reset evidence between cache release and reset-signal persistence. - Addressed the fifth Cloud Review API-auth ordering issue by adding an early `/api/v1/**` and raw `/cron/run` active-ban guard that blocks invalid/untrusted credentials and credentialed preflights before auth failure/success side effects, while a shared HMAC-backed credential resolver allows active trusted-user-owned API keys, including scheduler `?auth=` when enabled, to bypass the source ban under the trusted-user rule before downstream API/scheduler authorization runs. - Follow-up audit over prior review fixes found and closed two adjacent edges: auto-ban bare `403` responses now force access-log recording even on otherwise ignorable paths that still reach Symfony, and the pre-auth scheduler trusted-key bypass mirrors the scheduler `?auth=` token length/control-character guard before HMAC/DB lookup. +- Addressed the next Cloud Review round with separate reviewable commits: the auto-ban qualifying floor now counts distinct request IDs instead of scoreable rows, trusted scheduler credentials no longer create passive Visitor/IP source signals when scheduler responses are `403`/`404`, and Security signal retention settings below the maximum auto-ban TTL are rejected instead of allowing active bans to outlive their retained trigger evidence. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). From f372efee05a81cf22f4541b39734327a761d44e2 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 19:11:12 +0200 Subject: [PATCH 41/55] Guard persisted signal retention bounds --- dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 1 + src/Core/Config/ConfigValidationGuard.php | 17 ++++++ .../Config/Settings/CoreSettingsRegistry.php | 2 +- src/Core/Log/DatabaseLogRetentionPolicy.php | 32 +++++----- src/Security/AutoBan/AutoBanPolicy.php | 7 ++- .../Core/Config/ConfigValidationGuardTest.php | 21 +++++++ .../Config/CoreSettingsFormHandlerTest.php | 2 +- .../Core/Config/CoreSettingsRegistryTest.php | 2 +- tests/Core/Log/DatabaseLogBrowserTest.php | 5 +- .../Log/DatabaseLogRetentionPolicyTest.php | 58 +++++++++++++++++++ 11 files changed, 129 insertions(+), 22 deletions(-) create mode 100644 src/Core/Config/ConfigValidationGuard.php create mode 100644 tests/Core/Config/ConfigValidationGuardTest.php create mode 100644 tests/Core/Log/DatabaseLogRetentionPolicyTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 96df6239..eba0bbb5 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -128,7 +128,7 @@ | Type | Symbol | Purpose | Docs | Tests | |------|--------|---------|------|-------| -| Service | `App\Core\Config\Config`, `App\Core\Config\ConfigDefaultProviderInterface`, `App\Core\Config\Settings\CoreConfigDefaultProvider` | DBAL-backed configuration service with `get()` and `set()` helpers for JSON-encoded global config values, graceful fallback to centrally registered defaults when keys are missing or the database is not ready, and message-backed diagnostics for invalid keys, malformed values, and storage failures. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Config/ConfigTest.php`, `tests/Controller/PublicContentLocalizationTest.php` | +| Service | `App\Core\Config\Config`, `App\Core\Config\ConfigDefaultProviderInterface`, `App\Core\Config\ConfigValidationGuard`, `App\Core\Config\Settings\CoreConfigDefaultProvider` | DBAL-backed configuration service with `get()` and `set()` helpers for JSON-encoded global config values, graceful fallback to centrally registered defaults when keys are missing or the database is not ready, reusable runtime bounds normalization for already-persisted values, and message-backed diagnostics for invalid keys, malformed values, and storage failures. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Config/ConfigTest.php`, `tests/Core/Config/ConfigValidationGuardTest.php`, `tests/Controller/PublicContentLocalizationTest.php` | | Enum | `App\Core\Config\ConfigValueType` | Enum for typed database-backed configuration values. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, setting-level access rules, sensitive setting preservation, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, Security audit/signal policy controls, Log Settings database-retention controls, and Owner-only GeoIP provider configuration. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php`, `tests/Controller/BackendControllerTest.php` | | Service | `App\Core\Diagnostics\SystemInfoProvider` | Builds the Admin Settings System Information report with current preflight rows, redacted server/PHP/Composer diagnostics through the managed PHP CLI resolver when needed, image-processing capabilities, deterministic loaded-extension output, and reduced PHP configuration data without exposing request, cookie, environment, or secret dumps. | `dev/manual/admin-ui-snippets.md` | `tests/Controller/BackendControllerTest.php` | @@ -203,7 +203,7 @@ | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted GET/POST attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted code-bearing forms, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS` unless an active auto-ban response already owns the request, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | | Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | -| Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | +| Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Log/DatabaseLogRetentionPolicyTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index f3209948..6ef7bd85 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -109,6 +109,7 @@ - Addressed the fifth Cloud Review API-auth ordering issue by adding an early `/api/v1/**` and raw `/cron/run` active-ban guard that blocks invalid/untrusted credentials and credentialed preflights before auth failure/success side effects, while a shared HMAC-backed credential resolver allows active trusted-user-owned API keys, including scheduler `?auth=` when enabled, to bypass the source ban under the trusted-user rule before downstream API/scheduler authorization runs. - Follow-up audit over prior review fixes found and closed two adjacent edges: auto-ban bare `403` responses now force access-log recording even on otherwise ignorable paths that still reach Symfony, and the pre-auth scheduler trusted-key bypass mirrors the scheduler `?auth=` token length/control-character guard before HMAC/DB lookup. - Addressed the next Cloud Review round with separate reviewable commits: the auto-ban qualifying floor now counts distinct request IDs instead of scoreable rows, trusted scheduler credentials no longer create passive Visitor/IP source signals when scheduler responses are `403`/`404`, and Security signal retention settings below the maximum auto-ban TTL are rejected instead of allowing active bans to outlive their retained trigger evidence. +- Added a reusable config validation guard for effective runtime bounds so already-persisted `security.signals.retention_days` values are floored to the current maximum auto-ban TTL and capped at the global 30-day retention maximum, while ordinary log-retention settings keep their existing one-day minimum. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/src/Core/Config/ConfigValidationGuard.php b/src/Core/Config/ConfigValidationGuard.php new file mode 100644 index 00000000..0ffc52a1 --- /dev/null +++ b/src/Core/Config/ConfigValidationGuard.php @@ -0,0 +1,17 @@ + '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' => AutoBanPolicy::MAX_TTL_DAYS, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], metadata: [ + 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' => AutoBanPolicy::maxTtlDays(), '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: [ diff --git a/src/Core/Log/DatabaseLogRetentionPolicy.php b/src/Core/Log/DatabaseLogRetentionPolicy.php index 3077e7b4..cbebc78d 100644 --- a/src/Core/Log/DatabaseLogRetentionPolicy.php +++ b/src/Core/Log/DatabaseLogRetentionPolicy.php @@ -4,6 +4,8 @@ namespace App\Core\Log; +use App\Core\Config\ConfigValidationGuard; +use App\Security\AutoBan\AutoBanPolicy; use Doctrine\DBAL\Connection; use Throwable; @@ -17,45 +19,49 @@ public const DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS = 7; public const MAX_RETENTION_DAYS = 30; - public function __construct(private Connection $connection) - { + public function __construct( + private Connection $connection, + private ConfigValidationGuard $configValidation = new ConfigValidationGuard(), + ) { } public function retentionDaysForSource(string $source): int { return match ($source) { - 'audit' => $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), + 'audit' => $this->days(self::AUDIT_LOG_RETENTION_DAYS_KEY, self::DEFAULT_LOG_RETENTION_DAYS, 1), + 'access' => $this->days(self::ACCESS_LOG_RETENTION_DAYS_KEY, self::DEFAULT_LOG_RETENTION_DAYS, 1), + 'message' => $this->days(self::MESSAGE_LOG_RETENTION_DAYS_KEY, self::DEFAULT_LOG_RETENTION_DAYS, 1), default => self::DEFAULT_LOG_RETENTION_DAYS, }; } public function retentionDaysForSignal(): int { - return $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, + AutoBanPolicy::maxTtlDays(), + ); } - private function days(string $key, int $default): int + private function days(string $key, int $default, int $min): int { try { $encoded = $this->connection->fetchOne('SELECT value FROM config_entry WHERE config_key = ?', [$key]); } catch (Throwable) { - return $default; + return $this->configValidation->boundedInteger($default, $default, $min, self::MAX_RETENTION_DAYS); } if (!is_string($encoded)) { - return $default; + return $this->configValidation->boundedInteger($default, $default, $min, self::MAX_RETENTION_DAYS); } try { $value = json_decode($encoded, true, flags: JSON_THROW_ON_ERROR); } catch (Throwable) { - return $default; + return $this->configValidation->boundedInteger($default, $default, $min, self::MAX_RETENTION_DAYS); } - $days = is_int($value) ? $value : (is_numeric($value) ? (int) $value : $default); - - return max(1, min(self::MAX_RETENTION_DAYS, $days)); + return $this->configValidation->boundedInteger($value, $default, $min, self::MAX_RETENTION_DAYS); } } diff --git a/src/Security/AutoBan/AutoBanPolicy.php b/src/Security/AutoBan/AutoBanPolicy.php index 7e7be533..4b2ece6a 100644 --- a/src/Security/AutoBan/AutoBanPolicy.php +++ b/src/Security/AutoBan/AutoBanPolicy.php @@ -25,12 +25,15 @@ /** @var list */ public const TTL_ESCALATION_SECONDS = [3600, 10800, 86400, 604800]; - public const MAX_TTL_DAYS = 7; - public function __construct(private Config $config) { } + public static function maxTtlDays(): int + { + return (int) ceil(max(self::TTL_ESCALATION_SECONDS) / 86400); + } + public function enabled(): bool { return true === $this->config->get(self::ENABLED_KEY, self::DEFAULT_ENABLED); diff --git a/tests/Core/Config/ConfigValidationGuardTest.php b/tests/Core/Config/ConfigValidationGuardTest.php new file mode 100644 index 00000000..6366ef46 --- /dev/null +++ b/tests/Core/Config/ConfigValidationGuardTest.php @@ -0,0 +1,21 @@ +boundedInteger(1, 10, 7, 30)); + self::assertSame(30, $guard->boundedInteger(365, 10, 7, 30)); + self::assertSame(14, $guard->boundedInteger('14', 10, 7, 30)); + self::assertSame(10, $guard->boundedInteger('invalid', 10, 7, 30)); + } +} diff --git a/tests/Core/Config/CoreSettingsFormHandlerTest.php b/tests/Core/Config/CoreSettingsFormHandlerTest.php index 6e1fc7e6..a4e833f9 100644 --- a/tests/Core/Config/CoreSettingsFormHandlerTest.php +++ b/tests/Core/Config/CoreSettingsFormHandlerTest.php @@ -163,7 +163,7 @@ public function testItRejectsSecuritySignalRetentionBelowMaximumAutoBanTtl(): vo AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY => '1', ConfigAuditLogPolicy::ENABLED_KEY => '1', ConfigAuditLogPolicy::EVENTS_KEY => ConfigAuditLogPolicy::DEFAULT_CATEGORIES, - DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY => (string) (AutoBanPolicy::MAX_TTL_DAYS - 1), + DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY => (string) (AutoBanPolicy::maxTtlDays() - 1), SuspiciousProbePathMatcher::PATTERNS_KEY => SuspiciousProbePathMatcher::defaultPatternText(), ], 'test'); diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index 8380f3c4..1cac1a76 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -93,7 +93,7 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void self::assertSame(FormInputType::MultiSelect, $security[9]->formField()->inputType()); self::assertSame(ConfigAuditLogPolicy::DEFAULT_CATEGORIES, $security[9]->defaultValue()); self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $security[10]->defaultValue()); - self::assertSame(['min' => AutoBanPolicy::MAX_TTL_DAYS, 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], $security[10]->formField()->validation()); + self::assertSame(['min' => AutoBanPolicy::maxTtlDays(), 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], $security[10]->formField()->validation()); self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $security[11]->defaultValue()); self::assertSame(FormInputType::Textarea, $security[11]->formField()->inputType()); diff --git a/tests/Core/Log/DatabaseLogBrowserTest.php b/tests/Core/Log/DatabaseLogBrowserTest.php index dc1e2efd..46a55a69 100644 --- a/tests/Core/Log/DatabaseLogBrowserTest.php +++ b/tests/Core/Log/DatabaseLogBrowserTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Core\Log; use App\Core\Log\DatabaseLogBrowser; +use App\Security\AutoBan\AutoBanPolicy; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Connection; @@ -228,10 +229,10 @@ public function testItHonorsConfiguredSecuritySignalRetentionWhenBrowsing(): voi $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', + 'value' => (string) AutoBanPolicy::maxTtlDays(), ]); $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'); + $this->insertSignal($connection, '99999999-0000-7000-8000-000000000002', '2026-06-08 11:59:59', '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([ diff --git a/tests/Core/Log/DatabaseLogRetentionPolicyTest.php b/tests/Core/Log/DatabaseLogRetentionPolicyTest.php new file mode 100644 index 00000000..4ddad489 --- /dev/null +++ b/tests/Core/Log/DatabaseLogRetentionPolicyTest.php @@ -0,0 +1,58 @@ +connection(); + $this->insertConfig($connection, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 1); + + self::assertSame(AutoBanPolicy::maxTtlDays(), (new DatabaseLogRetentionPolicy($connection))->retentionDaysForSignal()); + } + + public function testSecuritySignalRetentionIsCappedAtMaximumRetention(): void + { + $connection = $this->connection(); + $this->insertConfig($connection, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 365); + + self::assertSame(DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS, (new DatabaseLogRetentionPolicy($connection))->retentionDaysForSignal()); + } + + public function testLogRetentionSourcesKeepOneDayMinimum(): void + { + $connection = $this->connection(); + $this->insertConfig($connection, DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, 1); + + self::assertSame(1, (new DatabaseLogRetentionPolicy($connection))->retentionDaysForSource('message')); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } + + private function insertConfig(Connection $connection, string $key, int $value): void + { + $connection->insert('config_entry', [ + 'config_key' => $key, + 'value' => json_encode($value, JSON_THROW_ON_ERROR), + 'value_type' => 'integer', + 'sensitive' => 0, + 'modified_at' => '2026-06-18 12:00:00', + 'modified_by' => 'test', + ]); + } +} From 2859e32f704c7714c3aaa255eb5584d3238530c0 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 19:14:03 +0200 Subject: [PATCH 42/55] Derive signal retention default from auto-ban TTL --- src/Core/Config/Settings/CoreSettingsRegistry.php | 2 +- src/Core/Log/DatabaseLogRetentionPolicy.php | 7 ++++++- src/Setup/SetupDefaultSeed.php | 2 +- tests/Core/Config/CoreSettingsRegistryTest.php | 2 +- tests/Core/Log/DatabaseLogRetentionPolicyTest.php | 6 ++++++ tests/Setup/SetupDefaultSeedTest.php | 2 +- 6 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index d007000d..781acf2d 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -129,7 +129,7 @@ public function allDefinitions(): array ], 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' => AutoBanPolicy::maxTtlDays(), 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], metadata: [ + new CoreSettingDefinition('security', DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 'admin.settings.fields.security_signal_retention_days.label', DatabaseLogRetentionPolicy::defaultSecuritySignalRetentionDays(), ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.security_signal_retention_days.help', validation: ['min' => AutoBanPolicy::maxTtlDays(), '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: [ diff --git a/src/Core/Log/DatabaseLogRetentionPolicy.php b/src/Core/Log/DatabaseLogRetentionPolicy.php index cbebc78d..3af87fb3 100644 --- a/src/Core/Log/DatabaseLogRetentionPolicy.php +++ b/src/Core/Log/DatabaseLogRetentionPolicy.php @@ -39,11 +39,16 @@ public function retentionDaysForSignal(): int { return $this->days( self::SECURITY_SIGNAL_RETENTION_DAYS_KEY, - self::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, + self::defaultSecuritySignalRetentionDays(), AutoBanPolicy::maxTtlDays(), ); } + public static function defaultSecuritySignalRetentionDays(): int + { + return max(self::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, AutoBanPolicy::maxTtlDays()); + } + private function days(string $key, int $default, int $min): int { try { diff --git a/src/Setup/SetupDefaultSeed.php b/src/Setup/SetupDefaultSeed.php index 86c540bd..aa20943c 100644 --- a/src/Setup/SetupDefaultSeed.php +++ b/src/Setup/SetupDefaultSeed.php @@ -53,7 +53,7 @@ public function configEntries(SetupInput $input): array ['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_RETENTION_DAYS_KEY, 'value' => $this->setting($input, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, DatabaseLogRetentionPolicy::defaultSecuritySignalRetentionDays()), 'type' => ConfigValueType::Integer], ['key' => SuspiciousProbePathMatcher::PATTERNS_KEY, 'value' => $this->setting($input, SuspiciousProbePathMatcher::PATTERNS_KEY, SuspiciousProbePathMatcher::defaultPatternText()), 'type' => ConfigValueType::String], ['key' => AutoBanPolicy::ENABLED_KEY, 'value' => $this->setupSetting($input, AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED), 'type' => ConfigValueType::Boolean], ['key' => AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 'value' => $this->setting($input, AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL), 'type' => ConfigValueType::Integer], diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index 1cac1a76..e0c8a0f0 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -92,7 +92,7 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void self::assertTrue($security[7]->defaultValue()); self::assertSame(FormInputType::MultiSelect, $security[9]->formField()->inputType()); self::assertSame(ConfigAuditLogPolicy::DEFAULT_CATEGORIES, $security[9]->defaultValue()); - self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $security[10]->defaultValue()); + self::assertSame(DatabaseLogRetentionPolicy::defaultSecuritySignalRetentionDays(), $security[10]->defaultValue()); self::assertSame(['min' => AutoBanPolicy::maxTtlDays(), 'max' => DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS], $security[10]->formField()->validation()); self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $security[11]->defaultValue()); self::assertSame(FormInputType::Textarea, $security[11]->formField()->inputType()); diff --git a/tests/Core/Log/DatabaseLogRetentionPolicyTest.php b/tests/Core/Log/DatabaseLogRetentionPolicyTest.php index 4ddad489..8a1a06b4 100644 --- a/tests/Core/Log/DatabaseLogRetentionPolicyTest.php +++ b/tests/Core/Log/DatabaseLogRetentionPolicyTest.php @@ -36,6 +36,12 @@ public function testLogRetentionSourcesKeepOneDayMinimum(): void self::assertSame(1, (new DatabaseLogRetentionPolicy($connection))->retentionDaysForSource('message')); } + public function testDefaultSecuritySignalRetentionIsValidForAutoBanTtl(): void + { + self::assertGreaterThanOrEqual(AutoBanPolicy::maxTtlDays(), DatabaseLogRetentionPolicy::defaultSecuritySignalRetentionDays()); + self::assertLessThanOrEqual(DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS, DatabaseLogRetentionPolicy::defaultSecuritySignalRetentionDays()); + } + private function connection(): Connection { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); diff --git a/tests/Setup/SetupDefaultSeedTest.php b/tests/Setup/SetupDefaultSeedTest.php index 508003ac..357e0904 100644 --- a/tests/Setup/SetupDefaultSeedTest.php +++ b/tests/Setup/SetupDefaultSeedTest.php @@ -39,7 +39,7 @@ public function testItBuildsInputAwareConfigDefaults(): void self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $settings[MaxMindGeoIpConfig::DATABASE_PATH_KEY]); self::assertSame('', $settings[MaxMindGeoIpConfig::LICENSE_KEY_KEY]); self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY]); - self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_SECURITY_SIGNAL_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY]); + self::assertSame(DatabaseLogRetentionPolicy::defaultSecuritySignalRetentionDays(), $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY]); self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $settings[SuspiciousProbePathMatcher::PATTERNS_KEY]); self::assertTrue($settings[AutoBanPolicy::ENABLED_KEY]); self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, $settings[AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY]); From 940b5e777b4e7f5ed273a4379ede9c48fdbd91de Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 19:19:05 +0200 Subject: [PATCH 43/55] Bound additional persisted config values --- dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 1 + .../Config/Settings/CoreSettingsRegistry.php | 8 +-- src/Security/AutoBan/AutoBanPolicy.php | 18 +++++-- src/Security/UserFlowConfig.php | 49 ++++++++++++------- tests/Core/Config/ConfigTest.php | 13 +++++ .../Core/Config/CoreSettingsRegistryTest.php | 17 +++++++ tests/Security/AutoBan/AutoBanPolicyTest.php | 14 ++++++ 8 files changed, 94 insertions(+), 30 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index eba0bbb5..a3c0c27e 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -96,7 +96,7 @@ | Security checker | `App\Security\UserAccountChecker` | Symfony form-login user checker that rejects inactive or deleted `UserAccount` records before authentication can create a session. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/SecurityControllerTest.php` | | Event subscriber | `App\Security\AppSecretRotationGuard` | Rejects unsupported short runtime `APP_SECRET` values before recovery handling, stores an environment-specific secret fingerprint, baselines first-seen secrets, and on detected rotation revokes active API keys while issuing password-reset links to active owners through the account-link delivery boundary; local Mercure hubs are stopped/refreshed around rotation when possible and marked unavailable if they cannot be safely stopped. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/AppSecretRotationGuardTest.php`, `tests/Controller/AdminUserControllerTest.php` | | Service | `App\Security\UserAccountLifecycle`, `App\Security\AdminUserAssignmentOptions`, `App\Security\AdminUserAccountUpdateService`, `App\Security\AdminUserAccountUpdateResult`, `App\Security\AdminUserPasswordResetService`, `App\Security\UserPasswordChangeService`, `App\Security\UserPasswordChangeResult`, `App\Security\UserAccountClosureService`, `App\Security\UserAccountClosureResult`, `App\Security\PasswordPolicy`, `App\Security\PasswordPolicyErrorMapper` | Applies account status changes, records current/last status state markers, revokes active API keys plus pending password-reset/security-review tokens when accounts become inactive or deleted, keeps admin assignment option filtering, account update mutations, deleted-account status changes, admin password-reset creation, authenticated password-change review-token delivery, and self-service account closure outside controllers, enforces the shared account password policy across setup, registration, reset, and profile changes, and maps policy violations to stable user-facing error keys outside controllers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php`, `tests/Controller/UserProfileControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Security/PasswordPolicyTest.php`, `tests/Security/PasswordPolicyErrorMapperTest.php` | -| Service | `App\Security\UserFlowConfig`, `App\Security\DeletedUserCleanup` | Reads database-backed user-flow settings for the system login menu, menu sort order, disabled/admin-approval/auto-approval registration mode, optional default ACL group, account-link TTL, profile username-change availability, validated notification recipients, and deleted-user retention; the cleanup service lists retained deleted accounts, reassigns their revoked API keys to the stable hidden deleted-user account, and permanently removes entries older than the configured retention for admin and future scheduler use. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php`, `tests/Core/Config/ConfigTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Service | `App\Security\UserFlowConfig`, `App\Security\DeletedUserCleanup` | Reads database-backed user-flow settings for the system login menu, bounds menu sort order, disabled/admin-approval/auto-approval registration mode, optional default ACL group, bounded account-link TTL, profile username-change availability, validated notification recipients, and bounded deleted-user retention; the cleanup service lists retained deleted accounts, reassigns their revoked API keys to the stable hidden deleted-user account, and permanently removes entries older than the configured retention for admin and future scheduler use. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php`, `tests/Core/Config/ConfigTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/BackendControllerTest.php` | | Event subscriber | `App\Security\MaintenanceModeSubscriber` | Enforces the environment-backed `APP_MAINTENANCE` flag by returning `503` for public requests while allowing admin-or-higher users plus admin, login, and asset bypass paths. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/MaintenanceModeSubscriberTest.php` | | Service | `App\Backend\BackendAccessGuard` | Converts the current Symfony user into an access actor and checks backend area access through the shared ACL resolver. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php` | | Service | `App\Backend\BackendActions`, `App\Backend\BackendActionResponder`, `App\Form\FormTokenValidator` | Provides admin maintenance actions for synchronous or LiveLog-backed package discovery, asset rebuild dispatch, cache clearing, and GeoIP database updates, with shared CSRF validation, translated flashes, JSON operation-start responses, audit logging for controller adapters, and optional Admin ACL feature metadata so visible-only actions render disabled while backend execution still rechecks mutability. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php` | @@ -202,7 +202,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, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted GET/POST attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted code-bearing forms, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS` unless an active auto-ban response already owns the request, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | -| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying signals, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Log/DatabaseLogRetentionPolicyTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 6ef7bd85..1472cf1e 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -110,6 +110,7 @@ - Follow-up audit over prior review fixes found and closed two adjacent edges: auto-ban bare `403` responses now force access-log recording even on otherwise ignorable paths that still reach Symfony, and the pre-auth scheduler trusted-key bypass mirrors the scheduler `?auth=` token length/control-character guard before HMAC/DB lookup. - Addressed the next Cloud Review round with separate reviewable commits: the auto-ban qualifying floor now counts distinct request IDs instead of scoreable rows, trusted scheduler credentials no longer create passive Visitor/IP source signals when scheduler responses are `403`/`404`, and Security signal retention settings below the maximum auto-ban TTL are rejected instead of allowing active bans to outlive their retained trigger evidence. - Added a reusable config validation guard for effective runtime bounds so already-persisted `security.signals.retention_days` values are floored to the current maximum auto-ban TTL and capped at the global 30-day retention maximum, while ordinary log-retention settings keep their existing one-day minimum. +- Extended the config validation guard to other small bounded runtime settings that already had form validation: user menu sort order, account-link TTL hours, deleted-user retention days, and the auto-ban score threshold now normalize already-persisted out-of-range values to their effective runtime bounds. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index 781acf2d..6e0d473e 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -66,12 +66,12 @@ public function allDefinitions(): array ], validation: ['required' => true], sortOrder: 10), new CoreSettingDefinition('users', UserFlowConfig::DEFAULT_ACL_GROUP_KEY, 'admin.settings.fields.default_acl_group.label', '', ConfigValueType::String, sortOrder: 20), new CoreSettingDefinition('users', UserFlowConfig::USERNAME_CHANGE_ENABLED_KEY, 'admin.settings.fields.username_change_enabled.label', false, ConfigValueType::Boolean, sortOrder: 30), - new CoreSettingDefinition('users', UserFlowConfig::ACCOUNT_LINK_TTL_HOURS_KEY, 'admin.settings.fields.account_link_ttl_hours.label', UserFlowConfig::DEFAULT_ACCOUNT_LINK_TTL_HOURS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.account_link_ttl_hours.help', validation: ['min' => 1, 'max' => 168], sortOrder: 40), + new CoreSettingDefinition('users', UserFlowConfig::ACCOUNT_LINK_TTL_HOURS_KEY, 'admin.settings.fields.account_link_ttl_hours.label', UserFlowConfig::DEFAULT_ACCOUNT_LINK_TTL_HOURS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.account_link_ttl_hours.help', validation: ['min' => UserFlowConfig::MIN_ACCOUNT_LINK_TTL_HOURS, 'max' => UserFlowConfig::MAX_ACCOUNT_LINK_TTL_HOURS], sortOrder: 40), new CoreSettingDefinition('users', UserFlowConfig::REGISTRATION_ADMIN_NOTIFICATION_EMAIL_KEY, 'admin.settings.fields.registration_admin_notification_email.label', '', ConfigValueType::String, validation: ['max_length' => 180], sortOrder: 50), new CoreSettingDefinition('users', UserFlowConfig::SECURITY_NOTIFICATION_EMAIL_KEY, 'admin.settings.fields.security_notification_email.label', '', ConfigValueType::String, validation: ['max_length' => 180], sortOrder: 60), - new CoreSettingDefinition('users', UserFlowConfig::DELETED_USER_RETENTION_DAYS_KEY, 'admin.settings.fields.deleted_user_retention_days.label', UserFlowConfig::DEFAULT_DELETED_USER_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.deleted_user_retention_days.help', validation: ['min' => 1, 'max' => 3650], sortOrder: 70), + new CoreSettingDefinition('users', UserFlowConfig::DELETED_USER_RETENTION_DAYS_KEY, 'admin.settings.fields.deleted_user_retention_days.label', UserFlowConfig::DEFAULT_DELETED_USER_RETENTION_DAYS, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.deleted_user_retention_days.help', validation: ['min' => UserFlowConfig::MIN_DELETED_USER_RETENTION_DAYS, 'max' => UserFlowConfig::MAX_DELETED_USER_RETENTION_DAYS], sortOrder: 70), new CoreSettingDefinition('users', 'user.menu.enabled', 'admin.settings.fields.user_menu_enabled.label', true, ConfigValueType::Boolean, sortOrder: 80), - new CoreSettingDefinition('users', 'user.menu.sort_order', 'admin.settings.fields.user_menu_sort_order.label', 900, ConfigValueType::Integer, FormInputType::Number, validation: ['min' => 0, 'max' => 9999], sortOrder: 90), + new CoreSettingDefinition('users', 'user.menu.sort_order', 'admin.settings.fields.user_menu_sort_order.label', UserFlowConfig::DEFAULT_MENU_SORT_ORDER, ConfigValueType::Integer, FormInputType::Number, validation: ['min' => UserFlowConfig::MIN_MENU_SORT_ORDER, 'max' => UserFlowConfig::MAX_MENU_SORT_ORDER], sortOrder: 90), new CoreSettingDefinition('mail', 'mail.enabled', 'admin.settings.fields.mail_enabled.label', false, ConfigValueType::Boolean, sortOrder: 10), new CoreSettingDefinition('mail', 'mail.from_address', 'admin.settings.fields.mail_from_address.label', 'admin@localhost', ConfigValueType::String, validation: ['max_length' => 180], sortOrder: 20), @@ -108,7 +108,7 @@ public function allDefinitions(): array 'access_feature' => 'admin.settings.security', 'minimum_access_level' => AccessLevel::OWNER, ], sortOrder: 37), - new CoreSettingDefinition('security', AutoBanPolicy::SCORE_THRESHOLD_KEY, 'admin.settings.fields.auto_ban_score_threshold.label', AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.auto_ban_score_threshold.help', validation: ['required' => true, 'min' => 2, 'max' => 10000], metadata: [ + new CoreSettingDefinition('security', AutoBanPolicy::SCORE_THRESHOLD_KEY, 'admin.settings.fields.auto_ban_score_threshold.label', AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, ConfigValueType::Integer, FormInputType::Number, help: 'admin.settings.fields.auto_ban_score_threshold.help', validation: ['required' => true, 'min' => AutoBanPolicy::MIN_SCORE_THRESHOLD, 'max' => AutoBanPolicy::MAX_SCORE_THRESHOLD], metadata: [ 'access_feature' => 'admin.settings.security', 'minimum_access_level' => AccessLevel::OWNER, ], sortOrder: 38), diff --git a/src/Security/AutoBan/AutoBanPolicy.php b/src/Security/AutoBan/AutoBanPolicy.php index 4b2ece6a..10edc14b 100644 --- a/src/Security/AutoBan/AutoBanPolicy.php +++ b/src/Security/AutoBan/AutoBanPolicy.php @@ -6,6 +6,7 @@ use App\Core\Access\AccessLevel; use App\Core\Config\Config; +use App\Core\Config\ConfigValidationGuard; final readonly class AutoBanPolicy { @@ -19,14 +20,18 @@ public const DEFAULT_NEW_BAN_OWNER_ALERTS = true; public const DEFAULT_TRUSTED_ACCESS_LEVEL = AccessLevel::MANAGER; public const DEFAULT_SCORE_THRESHOLD = 100; + public const MIN_SCORE_THRESHOLD = 2; + public const MAX_SCORE_THRESHOLD = 10000; public const IP_THRESHOLD_MULTIPLIER = 2; public const SCORE_WINDOW_SECONDS = 3600; public const MINIMUM_QUALIFYING_SIGNALS = 2; /** @var list */ public const TTL_ESCALATION_SECONDS = [3600, 10800, 86400, 604800]; - public function __construct(private Config $config) - { + public function __construct( + private Config $config, + private ConfigValidationGuard $configValidation = new ConfigValidationGuard(), + ) { } public static function maxTtlDays(): int @@ -53,9 +58,12 @@ public function trustedAccessLevel(): int public function visitorThreshold(): int { - $threshold = $this->config->get(self::SCORE_THRESHOLD_KEY, self::DEFAULT_SCORE_THRESHOLD); - - return max(2, is_numeric($threshold) ? (int) $threshold : self::DEFAULT_SCORE_THRESHOLD); + return $this->configValidation->boundedInteger( + $this->config->get(self::SCORE_THRESHOLD_KEY, self::DEFAULT_SCORE_THRESHOLD), + self::DEFAULT_SCORE_THRESHOLD, + self::MIN_SCORE_THRESHOLD, + self::MAX_SCORE_THRESHOLD, + ); } public function thresholdFor(string $subjectType): int diff --git a/src/Security/UserFlowConfig.php b/src/Security/UserFlowConfig.php index 387e8548..abb12473 100644 --- a/src/Security/UserFlowConfig.php +++ b/src/Security/UserFlowConfig.php @@ -5,6 +5,7 @@ namespace App\Security; use App\Core\Config\Config; +use App\Core\Config\ConfigValidationGuard; use App\Core\Validation\EmailAddress; final readonly class UserFlowConfig @@ -23,10 +24,19 @@ public const REGISTRATION_AUTO_APPROVAL = 'auto_approval'; public const DEFAULT_ACCOUNT_LINK_TTL_HOURS = 24; public const DEFAULT_DELETED_USER_RETENTION_DAYS = 7; + public const MIN_ACCOUNT_LINK_TTL_HOURS = 1; + public const MAX_ACCOUNT_LINK_TTL_HOURS = 168; + public const MIN_DELETED_USER_RETENTION_DAYS = 1; + public const MAX_DELETED_USER_RETENTION_DAYS = 3650; + public const DEFAULT_MENU_SORT_ORDER = 900; + public const MIN_MENU_SORT_ORDER = 0; + public const MAX_MENU_SORT_ORDER = 9999; public const PASSWORD_RESET_TTL = '+1 hour'; - public function __construct(private Config $config) - { + public function __construct( + private Config $config, + private ConfigValidationGuard $configValidation = new ConfigValidationGuard(), + ) { } public function menuEnabled(): bool @@ -36,9 +46,12 @@ public function menuEnabled(): bool public function menuSortOrder(): int { - $sortOrder = $this->config->get(self::MENU_SORT_ORDER_KEY) ?? 900; - - return is_int($sortOrder) ? $sortOrder : 900; + return $this->configValidation->boundedInteger( + $this->config->get(self::MENU_SORT_ORDER_KEY), + self::DEFAULT_MENU_SORT_ORDER, + self::MIN_MENU_SORT_ORDER, + self::MAX_MENU_SORT_ORDER, + ); } public function registrationEnabled(): bool @@ -76,24 +89,22 @@ public function accountLinkTtl(): string public function accountLinkTtlHours(): int { - $hours = $this->config->get(self::ACCOUNT_LINK_TTL_HOURS_KEY) ?? self::DEFAULT_ACCOUNT_LINK_TTL_HOURS; - - if (!is_int($hours)) { - return self::DEFAULT_ACCOUNT_LINK_TTL_HOURS; - } - - return max(1, min(168, $hours)); + return $this->configValidation->boundedInteger( + $this->config->get(self::ACCOUNT_LINK_TTL_HOURS_KEY), + self::DEFAULT_ACCOUNT_LINK_TTL_HOURS, + self::MIN_ACCOUNT_LINK_TTL_HOURS, + self::MAX_ACCOUNT_LINK_TTL_HOURS, + ); } public function deletedUserRetentionDays(): int { - $days = $this->config->get(self::DELETED_USER_RETENTION_DAYS_KEY) ?? self::DEFAULT_DELETED_USER_RETENTION_DAYS; - - if (!is_int($days)) { - return self::DEFAULT_DELETED_USER_RETENTION_DAYS; - } - - return max(1, min(3650, $days)); + return $this->configValidation->boundedInteger( + $this->config->get(self::DELETED_USER_RETENTION_DAYS_KEY), + self::DEFAULT_DELETED_USER_RETENTION_DAYS, + self::MIN_DELETED_USER_RETENTION_DAYS, + self::MAX_DELETED_USER_RETENTION_DAYS, + ); } public function registrationAdminNotificationEmail(): ?string diff --git a/tests/Core/Config/ConfigTest.php b/tests/Core/Config/ConfigTest.php index 786b1551..cc99ca01 100644 --- a/tests/Core/Config/ConfigTest.php +++ b/tests/Core/Config/ConfigTest.php @@ -35,12 +35,14 @@ public function testUserFlowConfigNormalizesTokenLifecycleSettings(): void { $connection = $this->connection(); $connection->insert('config_entry', ['config_key' => UserFlowConfig::ACCOUNT_LINK_TTL_HOURS_KEY, 'value' => '36', 'value_type' => 'integer']); + $connection->insert('config_entry', ['config_key' => UserFlowConfig::MENU_SORT_ORDER_KEY, 'value' => '-1', 'value_type' => 'integer']); $connection->insert('config_entry', ['config_key' => UserFlowConfig::REGISTRATION_ADMIN_NOTIFICATION_EMAIL_KEY, 'value' => '"Admin@Example.Test"', 'value_type' => 'string']); $connection->insert('config_entry', ['config_key' => UserFlowConfig::SECURITY_NOTIFICATION_EMAIL_KEY, 'value' => '"Security@Example.Test"', 'value_type' => 'string']); $config = new UserFlowConfig(new Config($connection)); self::assertSame(36, $config->accountLinkTtlHours()); self::assertSame('+36 hours', $config->accountLinkTtl()); + self::assertSame(UserFlowConfig::MIN_MENU_SORT_ORDER, $config->menuSortOrder()); self::assertSame('admin@example.test', $config->registrationAdminNotificationEmail()); self::assertSame('security@example.test', $config->securityNotificationEmail()); self::assertFalse($config->usernameChangeEnabled()); @@ -50,6 +52,17 @@ public function testUserFlowConfigNormalizesTokenLifecycleSettings(): void self::assertTrue($config->usernameChangeEnabled()); } + public function testUserFlowConfigBoundsPersistedLifecycleValues(): void + { + $connection = $this->connection(); + $connection->insert('config_entry', ['config_key' => UserFlowConfig::ACCOUNT_LINK_TTL_HOURS_KEY, 'value' => '999', 'value_type' => 'integer']); + $connection->insert('config_entry', ['config_key' => UserFlowConfig::DELETED_USER_RETENTION_DAYS_KEY, 'value' => '0', 'value_type' => 'integer']); + $config = new UserFlowConfig(new Config($connection)); + + self::assertSame(UserFlowConfig::MAX_ACCOUNT_LINK_TTL_HOURS, $config->accountLinkTtlHours()); + self::assertSame(UserFlowConfig::MIN_DELETED_USER_RETENTION_DAYS, $config->deletedUserRetentionDays()); + } + public function testItFallsBackWhenConfigurationCannotBeRead(): void { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index e0c8a0f0..312c6a58 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -60,6 +60,18 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void 'user.menu.enabled', 'user.menu.sort_order', ], array_map(static fn (CoreSettingDefinition $definition): string => $definition->key(), $users)); + self::assertSame([ + 'min' => UserFlowConfig::MIN_ACCOUNT_LINK_TTL_HOURS, + 'max' => UserFlowConfig::MAX_ACCOUNT_LINK_TTL_HOURS, + ], $users[3]->formField()->validation()); + self::assertSame([ + 'min' => UserFlowConfig::MIN_DELETED_USER_RETENTION_DAYS, + 'max' => UserFlowConfig::MAX_DELETED_USER_RETENTION_DAYS, + ], $users[6]->formField()->validation()); + self::assertSame([ + 'min' => UserFlowConfig::MIN_MENU_SORT_ORDER, + 'max' => UserFlowConfig::MAX_MENU_SORT_ORDER, + ], $users[8]->formField()->validation()); self::assertSame([ 'security.captcha.enabled', @@ -89,6 +101,11 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, $security[5]->defaultValue()); self::assertSame(FormInputType::Select, $security[5]->formField()->inputType()); self::assertSame(AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, $security[6]->defaultValue()); + self::assertSame([ + 'required' => true, + 'min' => AutoBanPolicy::MIN_SCORE_THRESHOLD, + 'max' => AutoBanPolicy::MAX_SCORE_THRESHOLD, + ], $security[6]->formField()->validation()); self::assertTrue($security[7]->defaultValue()); self::assertSame(FormInputType::MultiSelect, $security[9]->formField()->inputType()); self::assertSame(ConfigAuditLogPolicy::DEFAULT_CATEGORIES, $security[9]->defaultValue()); diff --git a/tests/Security/AutoBan/AutoBanPolicyTest.php b/tests/Security/AutoBan/AutoBanPolicyTest.php index 2fccb4ce..4548b092 100644 --- a/tests/Security/AutoBan/AutoBanPolicyTest.php +++ b/tests/Security/AutoBan/AutoBanPolicyTest.php @@ -42,4 +42,18 @@ public function testItUsesPersistedSetupEnabledValueWhenConfigStorageIsAvailable self::assertTrue((new AutoBanPolicy($config))->enabled()); } + + public function testItBoundsPersistedScoreThreshold(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(AutoBanPolicy::SCORE_THRESHOLD_KEY, 1, ConfigValueType::Integer); + + self::assertSame(AutoBanPolicy::MIN_SCORE_THRESHOLD, (new AutoBanPolicy($config))->visitorThreshold()); + + $config->set(AutoBanPolicy::SCORE_THRESHOLD_KEY, 10001, ConfigValueType::Integer); + + self::assertSame(AutoBanPolicy::MAX_SCORE_THRESHOLD, (new AutoBanPolicy($config))->visitorThreshold()); + } } From 235f9dc3f4ac8eea6526714e1b385dc86a786f74 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 19:51:24 +0200 Subject: [PATCH 44/55] Recheck recovery login bans after auth --- dev/CLASSMAP.md | 2 +- dev/draft/security-hardening/auto-ban.md | 8 ++--- dev/manual/security-guard-snippets.md | 2 +- .../AutoBan/AutoBanRequestSubscriber.php | 9 ++++-- .../AutoBan/AutoBanRequestSubscriberTest.php | 29 ++++++++++++++++++- 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index a3c0c27e..52afe371 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -202,7 +202,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, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted GET/POST attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted code-bearing forms, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS` unless an active auto-ban response already owns the request, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | -| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders and CSRF-marked recovery login submissions bypass active bans, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders bypass active bans, lets CSRF-marked recovery submissions reach authentication, and rechecks those submissions so only trusted contexts remain unblocked, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Log/DatabaseLogRetentionPolicyTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 1f2a25bd..916970e6 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -37,7 +37,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 7. Add cache-flock-backed active ban state with TTL plus a cache-backed active-ban index for Admin list rendering. The ban store and index must serialize index mutations, fail open when cache/lock storage is unavailable, and must never create an invisible permanent block. 8. Emit a persistent `security_signal_event` record when a ban is triggered, including whether the effective subject was `visitor` or `ip`, the TTL/escalation context, score summary, and safe references needed for Admin review without exposing raw IPs, raw visitor-cookie tokens, headers, secrets, or raw credentials. 9. Emit a Security signal when an Owner manually resets a ban. Reset success must require active cache-state release and observable reset-signal persistence. Serialize the release and reset-cutoff write with ban creation for the same subject key, release the active cache state first, then record the reset cutoff; if the reset signal cannot be persisted, restore the active state best-effort and report reset failure instead of leaving a cutoff for a ban that was not successfully released. Reset signals invalidate earlier retained signals for that same subject and subject type for future score and escalation calculations, so the visitor/IP starts at zero after reset. -10. Add enforcement early enough to run before controller/error-page rendering and before rate-limit buckets are consumed. `/api/v1/**` requests with an active source ban must be stopped before normal API-key authentication, authentication-failure signal/rate handling, CORS preflight success, or API controller handling unless a narrow early Bearer lookup proves the key is active and owned by a user at or above the trusted-user level. The raw `/cron/run` scheduler trigger uses the same early source-ban guard; valid trusted-user-owned Bearer keys and, when scheduler GET auth is enabled, valid trusted-user-owned `?auth=` keys may pass through to the scheduler's own read-write/Admin authorization checks. Invalid, revoked, unavailable, or below-trusted API keys must not pass through an active Visitor/IP ban. Browser enforcement still preserves the documented recovery-login render and marked-submit path. Active temporary bans return the shared forced bare `403 Forbidden` response with `Retry-After`, generic message, and safe Request ID only. +10. Add enforcement early enough to run before controller/error-page rendering and before rate-limit buckets are consumed. `/api/v1/**` requests with an active source ban must be stopped before normal API-key authentication, authentication-failure signal/rate handling, CORS preflight success, or API controller handling unless a narrow early Bearer lookup proves the key is active and owned by a user at or above the trusted-user level. The raw `/cron/run` scheduler trigger uses the same early source-ban guard; valid trusted-user-owned Bearer keys and, when scheduler GET auth is enabled, valid trusted-user-owned `?auth=` keys may pass through to the scheduler's own read-write/Admin authorization checks. Invalid, revoked, unavailable, or below-trusted API keys must not pass through an active Visitor/IP ban. Browser enforcement still preserves the documented recovery-login render path and lets marked recovery submissions reach authentication before the post-auth trusted-user recheck. Active temporary bans return the shared forced bare `403 Forbidden` response with `Retry-After`, generic message, and safe Request ID only. 11. Add Owner-gated Security settings UI fields for auto-ban enablement, trusted-user minimum access level, score threshold, and newly decided ban alerts. 12. Add the active-ban Admin list with subject type, safe subject label, created timestamp, TTL expiry, and detail link. The list and detail views use the existing non-configurable `admin.settings.security` ACL gate instead of a separate auto-ban gate. 13. Add the ban detail page with retained Security signals explaining the decision and an Owner-gated manual reset button. Detail rows are newest-first and may include retained pre-reset history for review context, but historical rows must never displace newer post-reset/current evidence from the bounded view. @@ -66,9 +66,9 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Evaluated Security signals are limited to source-risk signals. Routine access logs, ordinary successful requests, expected validation failures, login-required `401` responses, and shared ignorable static/tooling/well-known paths do not contribute by status alone. Normal application `404`, `403`, and `429` responses can still be weak signals because repeated misses, denials, or limiter collisions in a short window are source risk. - Honeypot/probe and obvious malformed/attack-pattern payload signals may carry high scores because they represent high-confidence scanner behavior. Payload scanning is limited to public/untrusted request surfaces and must skip Admin, Editor, Setup, and trusted-user contexts so legitimate code/template/content fields such as schema custom Twig are not recorded as probes. Payload signal context must store only pattern classes and safe parameter names/sources, never submitted raw values. Ordinary error-page and rate-limit hits must stay low enough that a single legitimate mistake is harmless while repeated hits can still cross the threshold. - Trusted registered users are never selected as auto-ban subjects. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, the required setting also protects Owners from being newly banned through trusted browser context. API keys are not active auto-ban subjects; `/api/v1/**` and raw `/cron/run` requests from an already banned Visitor/IP source are pre-auth blocked unless an early HMAC-backed lookup proves an active trusted-user-owned API key. Scheduler-specific read-write/Admin authorization remains the scheduler boundary, not an auto-ban decision. -- The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The normal login submission rendered from that recovery page must also be able to reach authentication so a trusted Owner context can be established, but only when it carries the explicit recovery marker rendered with that form. Ordinary `POST /user/login` submissions remain subject to active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. -- Ban decisions follow the Security policy enforcement order so recovery-login rendering and marked recovery submissions remain reachable, while active bans still run before error pages, API auth success/failure responses, API preflight handling, or rate-limit responses can be produced. -- `/api/live/**` stays outside ordinary rate-limit rejection, but it is not an active auto-ban bypass. Once a Visitor/IP source is actively banned, live JSON endpoints must receive the same bare `403` enforcement unless the trusted-user or recovery-login policy applies. +- The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The normal login submission rendered from that recovery page must also be able to reach authentication so a trusted Owner context can be established, but only when it carries the explicit recovery marker rendered with that form. After authentication, the active ban is rechecked and the submission remains unblocked only when the authenticated context satisfies the configured trusted-user level. Ordinary `POST /user/login` submissions remain subject to active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. +- Ban decisions follow the Security policy enforcement order so recovery-login rendering and marked recovery submissions remain reachable, while active bans still run before error pages, API auth success/failure responses, API preflight handling, or rate-limit responses can be produced. Marked recovery submissions are rechecked after authentication and are blocked again unless a trusted user context was established. +- `/api/live/**` stays outside ordinary rate-limit rejection, but it is not an active auto-ban bypass. Once a Visitor/IP source is actively banned, live JSON endpoints must receive the same bare `403` enforcement unless the trusted-user policy applies. - Config keys must be registered through the settings/default provider and setup seeder so setup, missing database, or unavailable database states read safe defaults without touching Doctrine/DBAL. The auto-ban enabled runtime fallback is disabled, while setup writes the completed-installation value as enabled. When the database is unavailable, signal persistence, score evaluation, Admin list/reset, and enforcement degrade fail-open. - Active temporary ban responses use the shared `HttpErrorRenderer` forced bare response path: `403 Forbidden`, `Retry-After` when TTL is known, `Cache-Control: no-store`, safe Request ID/reference, and a generic message. They must not expose score values, rule names, subject keys, Visitor IDs, IP buckets, raw IP data, paths, headers, signal internals, or ban history, and must not record additional Security signals for the auto-ban `403`. They still remain normal access-log entries so Owners can correlate Request ID, Visitor ID, status, and retained signal context during manual audits. - Cache-flock state is the active enforcement state holder. Explainability and escalation come from retained `security_signal_event` records, including ban-trigger and reset records, not from a separate durable ban table. diff --git a/dev/manual/security-guard-snippets.md b/dev/manual/security-guard-snippets.md index 8becda0d..2b7cd0c1 100644 --- a/dev/manual/security-guard-snippets.md +++ b/dev/manual/security-guard-snippets.md @@ -63,7 +63,7 @@ Auto-ban enforcement is temporary, source-subject based, and fail-open: - Score aggregation runs only after a scoreable `security_signal_event` write. Ordinary requests perform only the active cache-state check. - Active ban state lives in cache-backed TTL entries with a cache-backed Admin index. Retained Security signals explain trigger and reset history; there is no durable ban table. - Visitor ID is the primary source subject. IP bucket/HMAC is evaluated separately with a laxer threshold multiplier. -- Trusted registered users at or above the configured trusted level, trusted-user-owned API keys, the recovery login render path, and login submissions carrying the recovery marker rendered from that recovery path must bypass active Visitor/IP bans so trusted Owners can recover. +- Trusted registered users at or above the configured trusted level, trusted-user-owned API keys, and the recovery login render path must bypass active Visitor/IP bans so trusted Owners can recover. Login submissions carrying the recovery marker rendered from that recovery path may reach authentication, but active bans are rechecked after authentication unless the login establishes a trusted user context. - Owner review surfaces for active bans use the non-configurable `admin.settings.security` ACL gate. The browser list/detail views and `/api/v1/admin/security/auto-bans` endpoints must reject delegated non-Owner admins, and the API endpoint metadata must advertise the same Owner minimum access level. - Newly decided ban alerts are configurable and enabled by default. When enabled, active Owner accounts receive a hidden warning with an action link to the active-ban list. - Disabling auto-ban stops score evaluation and active-ban enforcement immediately. Existing TTL cache entries may remain until they expire, but they must not block requests while the feature is disabled. diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index bd7a93ad..214093c3 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -172,7 +172,7 @@ public function onKernelRequest(RequestEvent $event): void try { $inspection = $this->inspector->inspect($request); - if ($this->recoveryRequest($request, $inspection['profile']) || $this->trustedContext($inspection['subjects']->subjects())) { + if ($this->recoveryRenderRequest($inspection['profile']) || $this->trustedContext($inspection['subjects']->subjects())) { return; } @@ -233,7 +233,7 @@ private function activeBanFor(AbuseSubjectResolution $subjects): ?ActiveAutoBan private function recoveryRequest(Request $request, AbuseRequestProfile $profile): bool { - if (RequestIntent::RecoveryLogin === $profile->intent()) { + if ($this->recoveryRenderRequest($profile)) { return true; } @@ -243,6 +243,11 @@ private function recoveryRequest(Request $request, AbuseRequestProfile $profile) && $this->validRecoveryLoginToken($request); } + private function recoveryRenderRequest(AbuseRequestProfile $profile): bool + { + return RequestIntent::RecoveryLogin === $profile->intent(); + } + private function validRecoveryLoginToken(Request $request): bool { $token = $request->request->all()[self::RECOVERY_LOGIN_TOKEN_FIELD] ?? null; diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index e5a0810b..3a9f6a0d 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -243,13 +243,40 @@ public function testRecoveryLoginSubmissionsCanEstablishTrustedContextDespiteAct $request->attributes->set('_route', 'user_login'); $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000101', 'manager', 'manager@example.test', 'hash', role: UserRole::Manager); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); - $this->subscriber($visitorIds, $store, $clock, csrfTokens: $csrfTokens)->onKernelRequest($event); + $this->subscriber($visitorIds, $store, $clock, $tokenStorage, $csrfTokens)->onKernelRequest($event); self::assertNull($event->getResponse()); } + public function testRecoveryLoginSubmissionsWithoutTrustedContextAreRecheckedAfterAuthentication(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $csrfTokens = new CsrfTokenManager(); + $request = Request::create('/user/login', 'POST', [ + 'username' => 'member', + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => (string) $csrfTokens->getToken(AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_ID), + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000102', 'member', 'member@example.test', 'hash', role: UserRole::User); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock, $tokenStorage, $csrfTokens)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + public function testOrdinaryLoginSubmissionsDoNotBypassActiveBan(): void { $clock = new MockClock('2026-06-18 12:00:00'); From ced55c5f0e7b65abb1458af7cc5a6ed8bc5e5aa7 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 19:53:11 +0200 Subject: [PATCH 45/55] Skip trusted scheduler payload scoring --- dev/CLASSMAP.md | 2 +- dev/draft/security-hardening/auto-ban.md | 2 +- .../SuspiciousPayloadSignalSubscriber.php | 11 ++++++- tests/Controller/SchedulerControllerTest.php | 30 +++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 52afe371..1060c709 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -200,7 +200,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 high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | -| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted GET/POST attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted code-bearing forms, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | +| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted GET/POST attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted-user code-bearing forms plus valid trusted-user-owned scheduler credentials, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS` unless an active auto-ban response already owns the request, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | | Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders bypass active bans, lets CSRF-marked recovery submissions reach authentication, and rechecks those submissions so only trusted contexts remain unblocked, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Log/DatabaseLogRetentionPolicyTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 916970e6..370ce95c 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -64,7 +64,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - The first implementation uses stable code defaults in a score catalogue, with settings only for enablement, trusted-user minimum access level, and score threshold. Per-signal weight tuning may become configurable later only at the catalogue boundary with tests and policy updates. - The score is global per subject type/key, not separated into multiple buckets. Signal reasons decide weight; bucket family is diagnostic context only. - Evaluated Security signals are limited to source-risk signals. Routine access logs, ordinary successful requests, expected validation failures, login-required `401` responses, and shared ignorable static/tooling/well-known paths do not contribute by status alone. Normal application `404`, `403`, and `429` responses can still be weak signals because repeated misses, denials, or limiter collisions in a short window are source risk. -- Honeypot/probe and obvious malformed/attack-pattern payload signals may carry high scores because they represent high-confidence scanner behavior. Payload scanning is limited to public/untrusted request surfaces and must skip Admin, Editor, Setup, and trusted-user contexts so legitimate code/template/content fields such as schema custom Twig are not recorded as probes. Payload signal context must store only pattern classes and safe parameter names/sources, never submitted raw values. Ordinary error-page and rate-limit hits must stay low enough that a single legitimate mistake is harmless while repeated hits can still cross the threshold. +- Honeypot/probe and obvious malformed/attack-pattern payload signals may carry high scores because they represent high-confidence scanner behavior. Payload scanning is limited to public/untrusted request surfaces and must skip Admin, Editor, Setup, trusted-user contexts, and valid trusted-user-owned scheduler credentials so legitimate code/template/content fields such as schema custom Twig and trusted scheduler job inputs are not recorded as probes. Payload signal context must store only pattern classes and safe parameter names/sources, never submitted raw values. Ordinary error-page and rate-limit hits must stay low enough that a single legitimate mistake is harmless while repeated hits can still cross the threshold. - Trusted registered users are never selected as auto-ban subjects. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, the required setting also protects Owners from being newly banned through trusted browser context. API keys are not active auto-ban subjects; `/api/v1/**` and raw `/cron/run` requests from an already banned Visitor/IP source are pre-auth blocked unless an early HMAC-backed lookup proves an active trusted-user-owned API key. Scheduler-specific read-write/Admin authorization remains the scheduler boundary, not an auto-ban decision. - The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The normal login submission rendered from that recovery page must also be able to reach authentication so a trusted Owner context can be established, but only when it carries the explicit recovery marker rendered with that form. After authentication, the active ban is rechecked and the submission remains unblocked only when the authenticated context satisfies the configured trusted-user level. Ordinary `POST /user/login` submissions remain subject to active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. - Ban decisions follow the Security policy enforcement order so recovery-login rendering and marked recovery submissions remain reachable, while active bans still run before error pages, API auth success/failure responses, API preflight handling, or rate-limit responses can be produced. Marked recovery submissions are rechecked after authentication and are blocked again unless a trusted user context was established. diff --git a/src/Security/Abuse/SuspiciousPayloadSignalSubscriber.php b/src/Security/Abuse/SuspiciousPayloadSignalSubscriber.php index 8f30dc3b..c2086e66 100644 --- a/src/Security/Abuse/SuspiciousPayloadSignalSubscriber.php +++ b/src/Security/Abuse/SuspiciousPayloadSignalSubscriber.php @@ -9,7 +9,9 @@ use App\Core\Routing\IgnorableRequestPathMatcher; use App\Security\AutoBan\AutoBanPolicy; use App\Security\AutoBan\AutoBanRequestSubscriber; +use App\Security\AutoBan\TrustedApiKeyAutoBanBypass; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\KernelEvents; use Throwable; @@ -25,6 +27,7 @@ public function __construct( private AccessRequestMetadata $accessRequestMetadata, private ?AutoBanPolicy $autoBanPolicy = null, ?IgnorableRequestPathMatcher $ignorablePaths = null, + private ?TrustedApiKeyAutoBanBypass $trustedApiKeys = null, ) { $this->ignorablePaths = $ignorablePaths ?? new IgnorableRequestPathMatcher(); } @@ -47,7 +50,7 @@ public function onKernelRequest(RequestEvent $event): void $inspection = $this->inspector->inspect($request); $subjects = $inspection['subjects']; $profile = $inspection['profile']; - if ($this->safeApplicationInput($profile) || $this->trustedContext($subjects)) { + if ($this->safeApplicationInput($profile) || $this->trustedContext($subjects) || $this->trustedSchedulerCredential($request, $profile)) { return; } @@ -122,4 +125,10 @@ private function trustedContext(AbuseSubjectResolution $subjects): bool return is_numeric($level) && (int) $level >= ($this->autoBanPolicy?->trustedAccessLevel() ?? AccessLevel::MANAGER); } + + private function trustedSchedulerCredential(Request $request, AbuseRequestProfile $profile): bool + { + return RequestIntent::SchedulerTrigger === $profile->intent() + && true === $this->trustedApiKeys?->allows($request, allowPrefixlessBearer: true, allowSchedulerQuery: true); + } } diff --git a/tests/Controller/SchedulerControllerTest.php b/tests/Controller/SchedulerControllerTest.php index f67436b8..c832a6e2 100644 --- a/tests/Controller/SchedulerControllerTest.php +++ b/tests/Controller/SchedulerControllerTest.php @@ -114,6 +114,36 @@ public function testTrustedCronRunErrorsDoNotCreateSourceScoredSignals(): void } } + public function testTrustedCronRunPayloadPatternsDoNotCreateSourceScoredSignals(): void + { + $client = self::createClient(); + $connection = self::getContainer()->get(EntityManagerInterface::class)->getConnection(); + $config = self::getContainer()->get(Config::class); + self::assertInstanceOf(Config::class, $config); + + $connection->executeStatement('DELETE FROM security_signal_event'); + $client->request('GET', '/cron/run?job=../etc/passwd', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer test_seed_read_write_key', + 'REMOTE_ADDR' => '198.51.100.63', + ]); + + self::assertResponseStatusCodeSame(404); + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + + $config->set(SchedulerSettings::GET_AUTH_ENABLED_KEY, true, ConfigValueType::Boolean); + try { + $connection->executeStatement('DELETE FROM security_signal_event'); + $client->request('GET', '/cron/run?auth=test_seed_read_write_key&job=../etc/passwd', server: [ + 'REMOTE_ADDR' => '198.51.100.64', + ]); + + self::assertResponseStatusCodeSame(404); + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } finally { + $config->set(SchedulerSettings::GET_AUTH_ENABLED_KEY, false, ConfigValueType::Boolean); + } + } + public function testCronRunRejectsMalformedJobIdentifierBeforeRegistryLookup(): void { $client = self::createClient(); From 6aef450dd75179f32fcc8fe2a20c9b00db424f70 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 19:53:32 +0200 Subject: [PATCH 46/55] Document auto-ban review fixes --- dev/WORKLOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 1472cf1e..55a83fce 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -111,6 +111,7 @@ - Addressed the next Cloud Review round with separate reviewable commits: the auto-ban qualifying floor now counts distinct request IDs instead of scoreable rows, trusted scheduler credentials no longer create passive Visitor/IP source signals when scheduler responses are `403`/`404`, and Security signal retention settings below the maximum auto-ban TTL are rejected instead of allowing active bans to outlive their retained trigger evidence. - Added a reusable config validation guard for effective runtime bounds so already-persisted `security.signals.retention_days` values are floored to the current maximum auto-ban TTL and capped at the global 30-day retention maximum, while ordinary log-retention settings keep their existing one-day minimum. - Extended the config validation guard to other small bounded runtime settings that already had form validation: user menu sort order, account-link TTL hours, deleted-user retention days, and the auto-ban score threshold now normalize already-persisted out-of-range values to their effective runtime bounds. +- Addressed the latest Cloud Review round with separate reviewable commits: CSRF-marked recovery login submissions now only pass the post-auth active-ban recheck when authentication established a trusted user context, and valid trusted-user-owned scheduler credentials skip request-phase suspicious payload source scoring for both Bearer and enabled `?auth=` scheduler calls. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). From f096c2b206ec160b6f071824d7f2b15db84a63c9 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 20:34:10 +0200 Subject: [PATCH 47/55] Bound trusted auto-ban access level --- dev/draft/security-hardening/auto-ban.md | 2 +- dev/manual/security-guard-snippets.md | 2 +- .../Config/Settings/CoreSettingsRegistry.php | 7 ++++++- src/Security/AutoBan/AutoBanPolicy.php | 11 ++++++++--- tests/Core/Config/CoreSettingsRegistryTest.php | 5 +++++ tests/Security/AutoBan/AutoBanPolicyTest.php | 18 ++++++++++++++++++ translations/languages/de/admin.yaml | 5 +++++ translations/languages/en/admin.yaml | 5 +++++ 8 files changed, 49 insertions(+), 6 deletions(-) diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 370ce95c..aacead63 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -65,7 +65,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - The score is global per subject type/key, not separated into multiple buckets. Signal reasons decide weight; bucket family is diagnostic context only. - Evaluated Security signals are limited to source-risk signals. Routine access logs, ordinary successful requests, expected validation failures, login-required `401` responses, and shared ignorable static/tooling/well-known paths do not contribute by status alone. Normal application `404`, `403`, and `429` responses can still be weak signals because repeated misses, denials, or limiter collisions in a short window are source risk. - Honeypot/probe and obvious malformed/attack-pattern payload signals may carry high scores because they represent high-confidence scanner behavior. Payload scanning is limited to public/untrusted request surfaces and must skip Admin, Editor, Setup, trusted-user contexts, and valid trusted-user-owned scheduler credentials so legitimate code/template/content fields such as schema custom Twig and trusted scheduler job inputs are not recorded as probes. Payload signal context must store only pattern classes and safe parameter names/sources, never submitted raw values. Ordinary error-page and rate-limit hits must stay low enough that a single legitimate mistake is harmless while repeated hits can still cross the threshold. -- Trusted registered users are never selected as auto-ban subjects. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and cannot be empty. Because Owners have level `9`, the required setting also protects Owners from being newly banned through trusted browser context. API keys are not active auto-ban subjects; `/api/v1/**` and raw `/cron/run` requests from an already banned Visitor/IP source are pre-auth blocked unless an early HMAC-backed lookup proves an active trusted-user-owned API key. Scheduler-specific read-write/Admin authorization remains the scheduler boundary, not an auto-ban decision. +- Trusted registered users are never selected as auto-ban subjects. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and is bounded to valid registered-user access levels `1..9` so Owners may intentionally trust any logged-in user on closed-registration sites while invalid persisted values cannot make enforcement throw or trust anonymous users. Because Owners have level `9`, the required setting also protects Owners from being newly banned through trusted browser context. API keys are not active auto-ban subjects; `/api/v1/**` and raw `/cron/run` requests from an already banned Visitor/IP source are pre-auth blocked unless an early HMAC-backed lookup proves an active trusted-user-owned API key. Scheduler-specific read-write/Admin authorization remains the scheduler boundary, not an auto-ban decision. - The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The normal login submission rendered from that recovery page must also be able to reach authentication so a trusted Owner context can be established, but only when it carries the explicit recovery marker rendered with that form. After authentication, the active ban is rechecked and the submission remains unblocked only when the authenticated context satisfies the configured trusted-user level. Ordinary `POST /user/login` submissions remain subject to active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. - Ban decisions follow the Security policy enforcement order so recovery-login rendering and marked recovery submissions remain reachable, while active bans still run before error pages, API auth success/failure responses, API preflight handling, or rate-limit responses can be produced. Marked recovery submissions are rechecked after authentication and are blocked again unless a trusted user context was established. - `/api/live/**` stays outside ordinary rate-limit rejection, but it is not an active auto-ban bypass. Once a Visitor/IP source is actively banned, live JSON endpoints must receive the same bare `403` enforcement unless the trusted-user policy applies. diff --git a/dev/manual/security-guard-snippets.md b/dev/manual/security-guard-snippets.md index 2b7cd0c1..e8147c9c 100644 --- a/dev/manual/security-guard-snippets.md +++ b/dev/manual/security-guard-snippets.md @@ -63,7 +63,7 @@ Auto-ban enforcement is temporary, source-subject based, and fail-open: - Score aggregation runs only after a scoreable `security_signal_event` write. Ordinary requests perform only the active cache-state check. - Active ban state lives in cache-backed TTL entries with a cache-backed Admin index. Retained Security signals explain trigger and reset history; there is no durable ban table. - Visitor ID is the primary source subject. IP bucket/HMAC is evaluated separately with a laxer threshold multiplier. -- Trusted registered users at or above the configured trusted level, trusted-user-owned API keys, and the recovery login render path must bypass active Visitor/IP bans so trusted Owners can recover. Login submissions carrying the recovery marker rendered from that recovery path may reach authentication, but active bans are rechecked after authentication unless the login establishes a trusted user context. +- Trusted registered users at or above the configured trusted level, trusted-user-owned API keys, and the recovery login render path must bypass active Visitor/IP bans so trusted Owners can recover. The trusted level defaults to `MANAGER` and is bounded to valid registered-user levels `USER..OWNER`. Login submissions carrying the recovery marker rendered from that recovery path may reach authentication, but active bans are rechecked after authentication unless the login establishes a trusted user context. - Owner review surfaces for active bans use the non-configurable `admin.settings.security` ACL gate. The browser list/detail views and `/api/v1/admin/security/auto-bans` endpoints must reject delegated non-Owner admins, and the API endpoint metadata must advertise the same Owner minimum access level. - Newly decided ban alerts are configurable and enabled by default. When enabled, active Owner accounts receive a hidden warning with an action link to the active-ban list. - Disabling auto-ban stops score evaluation and active-ban enforcement immediately. Existing TTL cache entries may remain until they expire, but they must not block requests while the feature is disabled. diff --git a/src/Core/Config/Settings/CoreSettingsRegistry.php b/src/Core/Config/Settings/CoreSettingsRegistry.php index 6e0d473e..c6475cda 100644 --- a/src/Core/Config/Settings/CoreSettingsRegistry.php +++ b/src/Core/Config/Settings/CoreSettingsRegistry.php @@ -100,11 +100,16 @@ public function allDefinitions(): array 'minimum_access_level' => AccessLevel::OWNER, ], sortOrder: 36), new CoreSettingDefinition('security', AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 'admin.settings.fields.auto_ban_trusted_access_level.label', AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, ConfigValueType::Integer, FormInputType::Select, help: 'admin.settings.fields.auto_ban_trusted_access_level.help', options: [ + (string) AccessLevel::USER => 'admin.settings.options.access_level.user', + (string) AccessLevel::MODERATOR => 'admin.settings.options.access_level.moderator', + (string) AccessLevel::AUTHOR => 'admin.settings.options.access_level.author', + (string) AccessLevel::PUBLISHER => 'admin.settings.options.access_level.publisher', + (string) AccessLevel::CURATOR => 'admin.settings.options.access_level.curator', (string) AccessLevel::MANAGER => 'admin.settings.options.access_level.manager', (string) AccessLevel::DIRECTOR => 'admin.settings.options.access_level.director', (string) AccessLevel::ADMIN => 'admin.settings.options.access_level.admin', (string) AccessLevel::OWNER => 'admin.settings.options.access_level.owner', - ], validation: ['required' => true, 'min' => AccessLevel::MANAGER, 'max' => AccessLevel::OWNER], metadata: [ + ], validation: ['required' => true, 'min' => AutoBanPolicy::MIN_TRUSTED_ACCESS_LEVEL, 'max' => AutoBanPolicy::MAX_TRUSTED_ACCESS_LEVEL], metadata: [ 'access_feature' => 'admin.settings.security', 'minimum_access_level' => AccessLevel::OWNER, ], sortOrder: 37), diff --git a/src/Security/AutoBan/AutoBanPolicy.php b/src/Security/AutoBan/AutoBanPolicy.php index 10edc14b..8a17fc4a 100644 --- a/src/Security/AutoBan/AutoBanPolicy.php +++ b/src/Security/AutoBan/AutoBanPolicy.php @@ -19,6 +19,8 @@ public const SETUP_ENABLED = true; public const DEFAULT_NEW_BAN_OWNER_ALERTS = true; public const DEFAULT_TRUSTED_ACCESS_LEVEL = AccessLevel::MANAGER; + public const MIN_TRUSTED_ACCESS_LEVEL = AccessLevel::USER; + public const MAX_TRUSTED_ACCESS_LEVEL = AccessLevel::OWNER; public const DEFAULT_SCORE_THRESHOLD = 100; public const MIN_SCORE_THRESHOLD = 2; public const MAX_SCORE_THRESHOLD = 10000; @@ -51,9 +53,12 @@ public function newBanOwnerAlertsEnabled(): bool public function trustedAccessLevel(): int { - $level = $this->config->get(self::TRUSTED_ACCESS_LEVEL_KEY, self::DEFAULT_TRUSTED_ACCESS_LEVEL); - - return AccessLevel::assert(is_numeric($level) ? (int) $level : self::DEFAULT_TRUSTED_ACCESS_LEVEL) ?? self::DEFAULT_TRUSTED_ACCESS_LEVEL; + return $this->configValidation->boundedInteger( + $this->config->get(self::TRUSTED_ACCESS_LEVEL_KEY, self::DEFAULT_TRUSTED_ACCESS_LEVEL), + self::DEFAULT_TRUSTED_ACCESS_LEVEL, + self::MIN_TRUSTED_ACCESS_LEVEL, + self::MAX_TRUSTED_ACCESS_LEVEL, + ); } public function visitorThreshold(): int diff --git a/tests/Core/Config/CoreSettingsRegistryTest.php b/tests/Core/Config/CoreSettingsRegistryTest.php index 312c6a58..cd88e0af 100644 --- a/tests/Core/Config/CoreSettingsRegistryTest.php +++ b/tests/Core/Config/CoreSettingsRegistryTest.php @@ -100,6 +100,11 @@ public function testItDefinesKnownCoreSettingsForAdminForms(): void self::assertFalse($security[4]->defaultValue()); self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, $security[5]->defaultValue()); self::assertSame(FormInputType::Select, $security[5]->formField()->inputType()); + self::assertSame([ + 'required' => true, + 'min' => AutoBanPolicy::MIN_TRUSTED_ACCESS_LEVEL, + 'max' => AutoBanPolicy::MAX_TRUSTED_ACCESS_LEVEL, + ], $security[5]->formField()->validation()); self::assertSame(AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, $security[6]->defaultValue()); self::assertSame([ 'required' => true, diff --git a/tests/Security/AutoBan/AutoBanPolicyTest.php b/tests/Security/AutoBan/AutoBanPolicyTest.php index 4548b092..e23ad260 100644 --- a/tests/Security/AutoBan/AutoBanPolicyTest.php +++ b/tests/Security/AutoBan/AutoBanPolicyTest.php @@ -56,4 +56,22 @@ public function testItBoundsPersistedScoreThreshold(): void self::assertSame(AutoBanPolicy::MAX_SCORE_THRESHOLD, (new AutoBanPolicy($config))->visitorThreshold()); } + + public function testItBoundsPersistedTrustedAccessLevelToRegisteredUsers(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 0, ConfigValueType::Integer); + + self::assertSame(AutoBanPolicy::MIN_TRUSTED_ACCESS_LEVEL, (new AutoBanPolicy($config))->trustedAccessLevel()); + + $config->set(AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 10, ConfigValueType::Integer); + + self::assertSame(AutoBanPolicy::MAX_TRUSTED_ACCESS_LEVEL, (new AutoBanPolicy($config))->trustedAccessLevel()); + + $config->set(AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 'invalid', ConfigValueType::String); + + self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, (new AutoBanPolicy($config))->trustedAccessLevel()); + } } diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index d385549f..f78ea891 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -834,6 +834,11 @@ admin: strict: 'Streng' panic: 'Panik' access_level: + user: 'Benutzer' + moderator: 'Moderator' + author: 'Autor' + publisher: 'Publisher' + curator: 'Kurator' manager: 'Manager' director: 'Director' admin: 'Admin' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index f100b045..4eea756a 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -834,6 +834,11 @@ admin: strict: 'Strict' panic: 'Panic' access_level: + user: 'User' + moderator: 'Moderator' + author: 'Author' + publisher: 'Publisher' + curator: 'Curator' manager: 'Manager' director: 'Director' admin: 'Admin' From 7059c801d804d27cfe50646f52fdba2199873337 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 20:36:18 +0200 Subject: [PATCH 48/55] Recheck auto-bans after payload signals --- dev/CLASSMAP.md | 2 +- dev/draft/security-hardening/auto-ban.md | 4 +-- .../AutoBan/AutoBanRequestSubscriber.php | 13 +++++++++- .../AutoBan/AutoBanRequestSubscriberTest.php | 26 +++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 1060c709..e80b6c40 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -202,7 +202,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, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted GET/POST attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted-user code-bearing forms plus valid trusted-user-owned scheduler credentials, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS` unless an active auto-ban response already owns the request, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | -| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**`, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders bypass active bans, lets CSRF-marked recovery submissions reach authentication, and rechecks those submissions so only trusted contexts remain unblocked, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**` and rechecks after request-phase signal writes so newly created bans stop before controllers, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders bypass active bans, lets CSRF-marked recovery submissions reach authentication, and rechecks those submissions so only trusted contexts remain unblocked, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Log/DatabaseLogRetentionPolicyTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index aacead63..553d2d82 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -37,7 +37,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r 7. Add cache-flock-backed active ban state with TTL plus a cache-backed active-ban index for Admin list rendering. The ban store and index must serialize index mutations, fail open when cache/lock storage is unavailable, and must never create an invisible permanent block. 8. Emit a persistent `security_signal_event` record when a ban is triggered, including whether the effective subject was `visitor` or `ip`, the TTL/escalation context, score summary, and safe references needed for Admin review without exposing raw IPs, raw visitor-cookie tokens, headers, secrets, or raw credentials. 9. Emit a Security signal when an Owner manually resets a ban. Reset success must require active cache-state release and observable reset-signal persistence. Serialize the release and reset-cutoff write with ban creation for the same subject key, release the active cache state first, then record the reset cutoff; if the reset signal cannot be persisted, restore the active state best-effort and report reset failure instead of leaving a cutoff for a ban that was not successfully released. Reset signals invalidate earlier retained signals for that same subject and subject type for future score and escalation calculations, so the visitor/IP starts at zero after reset. -10. Add enforcement early enough to run before controller/error-page rendering and before rate-limit buckets are consumed. `/api/v1/**` requests with an active source ban must be stopped before normal API-key authentication, authentication-failure signal/rate handling, CORS preflight success, or API controller handling unless a narrow early Bearer lookup proves the key is active and owned by a user at or above the trusted-user level. The raw `/cron/run` scheduler trigger uses the same early source-ban guard; valid trusted-user-owned Bearer keys and, when scheduler GET auth is enabled, valid trusted-user-owned `?auth=` keys may pass through to the scheduler's own read-write/Admin authorization checks. Invalid, revoked, unavailable, or below-trusted API keys must not pass through an active Visitor/IP ban. Browser enforcement still preserves the documented recovery-login render path and lets marked recovery submissions reach authentication before the post-auth trusted-user recheck. Active temporary bans return the shared forced bare `403 Forbidden` response with `Retry-After`, generic message, and safe Request ID only. +10. Add enforcement early enough to run before controller/error-page rendering and before rate-limit buckets are consumed. `/api/v1/**` requests with an active source ban must be stopped before normal API-key authentication, authentication-failure signal/rate handling, CORS preflight success, or API controller handling unless a narrow early Bearer lookup proves the key is active and owned by a user at or above the trusted-user level. The raw `/cron/run` scheduler trigger uses the same early source-ban guard; valid trusted-user-owned Bearer keys and, when scheduler GET auth is enabled, valid trusted-user-owned `?auth=` keys may pass through to the scheduler's own read-write/Admin authorization checks. Invalid, revoked, unavailable, or below-trusted API keys must not pass through an active Visitor/IP ban. Browser enforcement still preserves the documented recovery-login render path and lets marked recovery submissions reach authentication before the post-auth trusted-user recheck. Request-phase payload scoring runs between two ordinary active-ban guards: the first suppresses additional signals for already-banned sources, and the second enforces a ban created by normal score evaluation before controller code runs. Active temporary bans return the shared forced bare `403 Forbidden` response with `Retry-After`, generic message, and safe Request ID only. 11. Add Owner-gated Security settings UI fields for auto-ban enablement, trusted-user minimum access level, score threshold, and newly decided ban alerts. 12. Add the active-ban Admin list with subject type, safe subject label, created timestamp, TTL expiry, and detail link. The list and detail views use the existing non-configurable `admin.settings.security` ACL gate instead of a separate auto-ban gate. 13. Add the ban detail page with retained Security signals explaining the decision and an Owner-gated manual reset button. Detail rows are newest-first and may include retained pre-reset history for review context, but historical rows must never displace newer post-reset/current evidence from the bounded view. @@ -67,7 +67,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - Honeypot/probe and obvious malformed/attack-pattern payload signals may carry high scores because they represent high-confidence scanner behavior. Payload scanning is limited to public/untrusted request surfaces and must skip Admin, Editor, Setup, trusted-user contexts, and valid trusted-user-owned scheduler credentials so legitimate code/template/content fields such as schema custom Twig and trusted scheduler job inputs are not recorded as probes. Payload signal context must store only pattern classes and safe parameter names/sources, never submitted raw values. Ordinary error-page and rate-limit hits must stay low enough that a single legitimate mistake is harmless while repeated hits can still cross the threshold. - Trusted registered users are never selected as auto-ban subjects. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and is bounded to valid registered-user access levels `1..9` so Owners may intentionally trust any logged-in user on closed-registration sites while invalid persisted values cannot make enforcement throw or trust anonymous users. Because Owners have level `9`, the required setting also protects Owners from being newly banned through trusted browser context. API keys are not active auto-ban subjects; `/api/v1/**` and raw `/cron/run` requests from an already banned Visitor/IP source are pre-auth blocked unless an early HMAC-backed lookup proves an active trusted-user-owned API key. Scheduler-specific read-write/Admin authorization remains the scheduler boundary, not an auto-ban decision. - The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The normal login submission rendered from that recovery page must also be able to reach authentication so a trusted Owner context can be established, but only when it carries the explicit recovery marker rendered with that form. After authentication, the active ban is rechecked and the submission remains unblocked only when the authenticated context satisfies the configured trusted-user level. Ordinary `POST /user/login` submissions remain subject to active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. -- Ban decisions follow the Security policy enforcement order so recovery-login rendering and marked recovery submissions remain reachable, while active bans still run before error pages, API auth success/failure responses, API preflight handling, or rate-limit responses can be produced. Marked recovery submissions are rechecked after authentication and are blocked again unless a trusted user context was established. +- Ban decisions follow the Security policy enforcement order so recovery-login rendering and marked recovery submissions remain reachable, while active bans still run before error pages, API auth success/failure responses, API preflight handling, or rate-limit responses can be produced. Request-phase payload signals are still ordinary score inputs; if the signal write creates a ban through normal score aggregation, a post-signal active-ban guard enforces that cache state before controller handling. Marked recovery submissions are rechecked after authentication and are blocked again unless a trusted user context was established. - `/api/live/**` stays outside ordinary rate-limit rejection, but it is not an active auto-ban bypass. Once a Visitor/IP source is actively banned, live JSON endpoints must receive the same bare `403` enforcement unless the trusted-user policy applies. - Config keys must be registered through the settings/default provider and setup seeder so setup, missing database, or unavailable database states read safe defaults without touching Doctrine/DBAL. The auto-ban enabled runtime fallback is disabled, while setup writes the completed-installation value as enabled. When the database is unavailable, signal persistence, score evaluation, Admin list/reset, and enforcement degrade fail-open. - Active temporary ban responses use the shared `HttpErrorRenderer` forced bare response path: `403 Forbidden`, `Retry-After` when TTL is known, `Cache-Control: no-store`, safe Request ID/reference, and a generic message. They must not expose score values, rule names, subject keys, Visitor IDs, IP buckets, raw IP data, paths, headers, signal internals, or ban history, and must not record additional Security signals for the auto-ban `403`. They still remain normal access-log entries so Owners can correlate Request ID, Visitor ID, status, and retained signal context during manual audits. diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index 214093c3..fbd10bd8 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -67,6 +67,7 @@ public static function getSubscribedEvents(): array ['onKernelRequestProbeCandidate', 4097], ['onKernelRequestLogin', 16], ['onKernelRequest', 4], + ['onKernelRequestAfterSignalWrites', 1], ], ]; } @@ -160,6 +161,16 @@ public function onKernelRequestLogin(RequestEvent $event): void } public function onKernelRequest(RequestEvent $event): void + { + $this->enforceActiveBan($event, 'request_enforcement'); + } + + public function onKernelRequestAfterSignalWrites(RequestEvent $event): void + { + $this->enforceActiveBan($event, 'post_signal_request_enforcement'); + } + + private function enforceActiveBan(RequestEvent $event, string $operation): void { if (!$event->isMainRequest() || !$this->enabledForRequest($event->getRequest()) || !$this->policy->enabled()) { return; @@ -183,7 +194,7 @@ public function onKernelRequest(RequestEvent $event): void } } catch (Throwable $error) { $this->reportEvaluation($error, [ - 'operation' => 'request_enforcement', + 'operation' => $operation, 'path' => $request->getPathInfo(), ]); diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index 3a9f6a0d..a6709a6a 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -15,6 +15,7 @@ use App\Security\Abuse\AbuseSubjectResolver; use App\Security\Abuse\ActionCostCatalogue; use App\Security\Abuse\RequestIntentClassifier; +use App\Security\Abuse\SuspiciousPayloadSignalSubscriber; use App\Security\AutoBan\AutoBanPolicy; use App\Security\AutoBan\AutoBanRequestSubscriber; use App\Security\AutoBan\AutoBanStore; @@ -345,6 +346,27 @@ public function testLiveEndpointsDoNotBypassActiveBans(): void self::assertSame(403, $event->getResponse()?->getStatusCode()); } + public function testPostSignalGuardBlocksBansCreatedAfterTheFinalPreSignalGuard(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/search', 'GET', ['q' => 'probe'], server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $subscriber = $this->subscriber($visitorIds, $store, $clock); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $subscriber->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + + $store->ban($subject, 3600); + $subscriber->onKernelRequestAfterSignalWrites($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + public function testTrustedUsersBypassActiveVisitorBans(): void { $clock = new MockClock('2026-06-18 12:00:00'); @@ -367,15 +389,19 @@ public function testSubscriberRunsAfterSecurityContextButBeforeOrdinaryRateLimit { $autoBan = AutoBanRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; $rateLimit = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + $payloadSignals = SuspiciousPayloadSignalSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; self::assertSame(['onKernelRequestPreAuthSourceBan', 4098], $autoBan[0]); self::assertSame(['onKernelRequestProbeCandidate', 4097], $autoBan[1]); self::assertSame(['onKernelRequestLogin', 16], $autoBan[2]); self::assertSame(['onKernelRequest', 4], $autoBan[3]); + self::assertSame(['onKernelRequestAfterSignalWrites', 1], $autoBan[4]); self::assertGreaterThan($rateLimit[0][1], $autoBan[0][1]); self::assertGreaterThan($rateLimit[0][1], $autoBan[1][1]); self::assertSame(['onKernelRequestOrdinary', 3], $rateLimit[1]); self::assertGreaterThan($rateLimit[1][1], $autoBan[3][1]); + self::assertGreaterThan($autoBan[4][1], $payloadSignals[1]); + self::assertLessThan($autoBan[3][1], $payloadSignals[1]); } private function subscriber( From 7d1f569cf8c05b4ad75365be5d779ed71d7c2d7a Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 20:37:57 +0200 Subject: [PATCH 49/55] Scan JSON bodies for payload probes --- dev/CLASSMAP.md | 2 +- dev/draft/security-hardening/auto-ban.md | 2 +- .../Abuse/SuspiciousRequestPayloadMatcher.php | 42 ++++++++++++- .../SuspiciousPayloadSignalSubscriberTest.php | 59 +++++++++++++++++++ .../SuspiciousRequestPayloadMatcherTest.php | 36 +++++++++++ 5 files changed, 138 insertions(+), 3 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index e80b6c40..0b94564e 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -200,7 +200,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 high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | -| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted GET/POST attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted-user code-bearing forms plus valid trusted-user-owned scheduler credentials, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | +| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted query, form, and bounded JSON/raw-body attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted-user code-bearing forms plus valid trusted-user-owned scheduler credentials, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS` unless an active auto-ban response already owns the request, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | | Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**` and rechecks after request-phase signal writes so newly created bans stop before controllers, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders bypass active bans, lets CSRF-marked recovery submissions reach authentication, and rechecks those submissions so only trusted contexts remain unblocked, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Log/DatabaseLogRetentionPolicyTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | diff --git a/dev/draft/security-hardening/auto-ban.md b/dev/draft/security-hardening/auto-ban.md index 553d2d82..611bdded 100644 --- a/dev/draft/security-hardening/auto-ban.md +++ b/dev/draft/security-hardening/auto-ban.md @@ -64,7 +64,7 @@ The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be r - The first implementation uses stable code defaults in a score catalogue, with settings only for enablement, trusted-user minimum access level, and score threshold. Per-signal weight tuning may become configurable later only at the catalogue boundary with tests and policy updates. - The score is global per subject type/key, not separated into multiple buckets. Signal reasons decide weight; bucket family is diagnostic context only. - Evaluated Security signals are limited to source-risk signals. Routine access logs, ordinary successful requests, expected validation failures, login-required `401` responses, and shared ignorable static/tooling/well-known paths do not contribute by status alone. Normal application `404`, `403`, and `429` responses can still be weak signals because repeated misses, denials, or limiter collisions in a short window are source risk. -- Honeypot/probe and obvious malformed/attack-pattern payload signals may carry high scores because they represent high-confidence scanner behavior. Payload scanning is limited to public/untrusted request surfaces and must skip Admin, Editor, Setup, trusted-user contexts, and valid trusted-user-owned scheduler credentials so legitimate code/template/content fields such as schema custom Twig and trusted scheduler job inputs are not recorded as probes. Payload signal context must store only pattern classes and safe parameter names/sources, never submitted raw values. Ordinary error-page and rate-limit hits must stay low enough that a single legitimate mistake is harmless while repeated hits can still cross the threshold. +- Honeypot/probe and obvious malformed/attack-pattern payload signals may carry high scores because they represent high-confidence scanner behavior. Payload scanning is limited to public/untrusted request surfaces and covers query parameters, form parameters, and bounded JSON/raw JSON-like body metadata. It must skip Admin, Editor, Setup, trusted-user contexts, and valid trusted-user-owned scheduler credentials so legitimate code/template/content fields such as schema custom Twig and trusted scheduler job inputs are not recorded as probes. Payload signal context must store only pattern classes and safe parameter names/sources, never submitted raw values. Ordinary error-page and rate-limit hits must stay low enough that a single legitimate mistake is harmless while repeated hits can still cross the threshold. - Trusted registered users are never selected as auto-ban subjects. The trusted-user minimum access level is required, defaults to `6`/`MANAGER`, and is bounded to valid registered-user access levels `1..9` so Owners may intentionally trust any logged-in user on closed-registration sites while invalid persisted values cannot make enforcement throw or trust anonymous users. Because Owners have level `9`, the required setting also protects Owners from being newly banned through trusted browser context. API keys are not active auto-ban subjects; `/api/v1/**` and raw `/cron/run` requests from an already banned Visitor/IP source are pre-auth blocked unless an early HMAC-backed lookup proves an active trusted-user-owned API key. Scheduler-specific read-write/Admin authorization remains the scheduler boundary, not an auto-ban decision. - The recovery login render path `/user/login?bypass=1`, resolved through the shared `RequestPathResolver`, stays reachable despite active Visitor/IP bans. The normal login submission rendered from that recovery page must also be able to reach authentication so a trusted Owner context can be established, but only when it carries the explicit recovery marker rendered with that form. After authentication, the active ban is rechecked and the submission remains unblocked only when the authenticated context satisfies the configured trusted-user level. Ordinary `POST /user/login` submissions remain subject to active Visitor/IP bans. The bypass route keeps its separate strict rate limiting and does not bypass CSRF, credential validation, login-failure accounting, audit logging, or post-login policy re-evaluation. - Ban decisions follow the Security policy enforcement order so recovery-login rendering and marked recovery submissions remain reachable, while active bans still run before error pages, API auth success/failure responses, API preflight handling, or rate-limit responses can be produced. Request-phase payload signals are still ordinary score inputs; if the signal write creates a ban through normal score aggregation, a post-signal active-ban guard enforces that cache state before controller handling. Marked recovery submissions are rechecked after authentication and are blocked again unless a trusted user context was established. diff --git a/src/Security/Abuse/SuspiciousRequestPayloadMatcher.php b/src/Security/Abuse/SuspiciousRequestPayloadMatcher.php index 87b8ce2e..23c12007 100644 --- a/src/Security/Abuse/SuspiciousRequestPayloadMatcher.php +++ b/src/Security/Abuse/SuspiciousRequestPayloadMatcher.php @@ -10,6 +10,7 @@ { private const MAX_PARAMETERS = 80; private const MAX_STRING_LENGTH = 2048; + private const MAX_BODY_LENGTH = 8192; /** * @var list @@ -52,7 +53,7 @@ public function match(Request $request): ?array $signatures = []; $parameters = []; - foreach (['query' => $request->query->all(), 'request' => $request->request->all()] as $source => $payload) { + foreach (['query' => $request->query->all(), 'request' => $request->request->all(), ...$this->bodyPayload($request)] as $source => $payload) { $this->scanPayload($source, $payload, $signatures, $parameters); } @@ -66,6 +67,45 @@ public function match(Request $request): ?array ]; } + /** + * @return array> + */ + private function bodyPayload(Request $request): array + { + if (!$this->jsonLikeRequest($request)) { + return []; + } + + $content = mb_substr($request->getContent(), 0, self::MAX_BODY_LENGTH); + if ('' === trim($content)) { + return []; + } + + try { + $decoded = json_decode($content, true, 32, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return ['raw_body' => ['body' => $content]]; + } + + if (is_array($decoded)) { + return ['json' => $decoded]; + } + + return ['json' => ['body' => $decoded]]; + } + + private function jsonLikeRequest(Request $request): bool + { + $contentType = strtolower((string) $request->headers->get('Content-Type')); + if (str_contains($contentType, '/json') || str_contains($contentType, '+json')) { + return true; + } + + $content = ltrim(mb_substr($request->getContent(), 0, 32)); + + return str_starts_with($content, '{') || str_starts_with($content, '['); + } + /** * @param array $payload * @param list $signatures diff --git a/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php b/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php index 63a7eab0..cf0404b2 100644 --- a/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php +++ b/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php @@ -81,6 +81,40 @@ public function testItRecordsMalformedSecurityParameterSignals(): void self::assertSame('username', $context['payload_parameters'][0]['name']); } + public function testItRecordsJsonApiPayloadSignals(): void + { + $connection = $this->connection(); + $visitorIds = new VisitorIdGenerator('test-secret'); + $request = Request::create( + '/api/v1/search', + 'POST', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'REMOTE_ADDR' => '203.0.113.10', + ], + content: json_encode([ + 'filter' => [ + 'query' => "x' UNION SELECT password FROM users --", + ], + ], JSON_THROW_ON_ERROR), + ); + + $this->subscriber($connection, $visitorIds, new AccessRequestMetadata())->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + + self::assertSame(2, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + $row = $connection->fetchAssociative("SELECT * FROM security_signal_event WHERE subject_type = 'visitor'"); + self::assertIsArray($row); + $context = json_decode((string) $row['context'], true, flags: JSON_THROW_ON_ERROR); + self::assertContains('sql_union_select', $context['payload_signatures']); + self::assertSame('json', $context['payload_parameters'][0]['source']); + self::assertSame('filter.query', $context['payload_parameters'][0]['name']); + self::assertStringNotContainsString('UNION SELECT', json_encode([$row, $context], JSON_THROW_ON_ERROR)); + } + public function testItSkipsAutoBanEnforcementRequests(): void { $connection = $this->connection(); @@ -120,6 +154,31 @@ public function testItSkipsAdminEditorPayloadsThatMayContainCustomCode(): void self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); } + public function testItSkipsAdminJsonPayloadsThatMayContainCustomCode(): void + { + $connection = $this->connection(); + $visitorIds = new VisitorIdGenerator('test-secret'); + $request = Request::create( + '/admin/content/schemas', + 'POST', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'REMOTE_ADDR' => '203.0.113.10', + ], + content: json_encode([ + 'custom_twig' => '', + ], JSON_THROW_ON_ERROR), + ); + + $this->subscriber($connection, $visitorIds, new AccessRequestMetadata())->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + private function subscriber(Connection $connection, VisitorIdGenerator $visitorIds, AccessRequestMetadata $metadata): SuspiciousPayloadSignalSubscriber { return new SuspiciousPayloadSignalSubscriber( diff --git a/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php b/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php index 53f4f339..b913abef 100644 --- a/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php +++ b/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php @@ -41,4 +41,40 @@ public function testItDetectsMalformedSecurityParametersButAllowsOrdinaryArrays( self::assertSame('username', $malformed['parameters'][0]['name']); self::assertNull($ordinary); } + + public function testItDetectsJsonBodyAttackSignaturesWithoutReturningRawPayloads(): void + { + $match = (new SuspiciousRequestPayloadMatcher())->match(Request::create( + '/api/v1/search', + 'POST', + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode([ + 'filter' => [ + 'query' => "x' UNION SELECT password FROM users --", + ], + ], JSON_THROW_ON_ERROR), + )); + + self::assertIsArray($match); + self::assertContains('sql_union_select', $match['signatures']); + self::assertSame('json', $match['parameters'][0]['source']); + self::assertSame('filter.query', $match['parameters'][0]['name']); + self::assertStringNotContainsString('UNION SELECT', json_encode($match, JSON_THROW_ON_ERROR)); + } + + public function testItDetectsJsonLikeRawBodyAttackSignatures(): void + { + $match = (new SuspiciousRequestPayloadMatcher())->match(Request::create( + '/api/v1/search', + 'POST', + server: ['CONTENT_TYPE' => 'application/json'], + content: '{"query":"../../etc/passwd"', + )); + + self::assertIsArray($match); + self::assertContains('sensitive_file_probe', $match['signatures']); + self::assertSame('raw_body', $match['parameters'][0]['source']); + self::assertSame('body', $match['parameters'][0]['name']); + self::assertStringNotContainsString('/etc/passwd', json_encode($match, JSON_THROW_ON_ERROR)); + } } From f2bfa76fe89cde2df1f77cc01f2334aa4949cc0c Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 20:38:12 +0200 Subject: [PATCH 50/55] Document auto-ban review updates --- dev/WORKLOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 55a83fce..09c411f4 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -112,6 +112,7 @@ - Added a reusable config validation guard for effective runtime bounds so already-persisted `security.signals.retention_days` values are floored to the current maximum auto-ban TTL and capped at the global 30-day retention maximum, while ordinary log-retention settings keep their existing one-day minimum. - Extended the config validation guard to other small bounded runtime settings that already had form validation: user menu sort order, account-link TTL hours, deleted-user retention days, and the auto-ban score threshold now normalize already-persisted out-of-range values to their effective runtime bounds. - Addressed the latest Cloud Review round with separate reviewable commits: CSRF-marked recovery login submissions now only pass the post-auth active-ban recheck when authentication established a trusted user context, and valid trusted-user-owned scheduler credentials skip request-phase suspicious payload source scoring for both Bearer and enabled `?auth=` scheduler calls. +- Addressed the next Cloud Review round with separate reviewable commits: trusted auto-ban access level is bounded to valid registered-user levels `USER..OWNER` while keeping the Owner-selectable policy range, request-phase payload signal writes are followed by a second active-ban guard so newly created bans stop before controllers, and payload matching now scans bounded JSON/raw JSON-like body metadata under the same public/untrusted context guards without storing raw submitted values. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). From e888d786c97a065f6e98dffa01529a5328c162b5 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 21:01:55 +0200 Subject: [PATCH 51/55] Suppress recovery auth failures after auto-ban --- .../AutoBan/AutoBanRequestSubscriber.php | 30 ++++++++ .../AutoBan/AutoBanRequestSubscriberTest.php | 71 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index fbd10bd8..744c8479 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -28,6 +28,7 @@ use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Throwable; final readonly class AutoBanRequestSubscriber implements EventSubscriberInterface @@ -35,6 +36,7 @@ public const PASSIVE_SIGNAL_SKIP_ATTRIBUTE = '_system_auto_ban_response'; public const PROBE_RATE_LIMIT_SKIP_ATTRIBUTE = '_system_auto_ban_skip_probe_rate_limit'; public const TRUSTED_PRE_AUTH_BYPASS_ATTRIBUTE = '_system_auto_ban_trusted_pre_auth_bypass'; + public const RECOVERY_ACTIVE_BAN_KEY_ATTRIBUTE = '_system_auto_ban_recovery_active_ban_key'; public const RECOVERY_LOGIN_TOKEN_FIELD = '_auto_ban_recovery_token'; public const RECOVERY_LOGIN_TOKEN_ID = 'auto_ban_recovery_login'; @@ -69,6 +71,7 @@ public static function getSubscribedEvents(): array ['onKernelRequest', 4], ['onKernelRequestAfterSignalWrites', 1], ], + LoginFailureEvent::class => ['onLoginFailure', 64], ]; } @@ -140,6 +143,12 @@ public function onKernelRequestLogin(RequestEvent $event): void try { $inspection = $this->inspector->inspect($request); if ($this->recoveryRequest($request, $inspection['profile'])) { + $ban = $this->activeBanFor($inspection['subjects']); + if ($ban instanceof ActiveAutoBan) { + $request->attributes->set(self::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + $request->attributes->set(self::RECOVERY_ACTIVE_BAN_KEY_ATTRIBUTE, $ban->key()); + } + return; } @@ -170,6 +179,27 @@ public function onKernelRequestAfterSignalWrites(RequestEvent $event): void $this->enforceActiveBan($event, 'post_signal_request_enforcement'); } + public function onLoginFailure(LoginFailureEvent $event): void + { + $request = $event->getRequest(); + if (!$this->enabledForRequest($request) || !$this->policy->enabled()) { + return; + } + + $key = $request->attributes->get(self::RECOVERY_ACTIVE_BAN_KEY_ATTRIBUTE); + if (!is_string($key) || '' === $key) { + return; + } + + $ban = $this->store->activeByKey($key); + if (!$ban instanceof ActiveAutoBan) { + return; + } + + $event->setResponse($this->banResponse($request, $ban)); + $request->attributes->set(self::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + } + private function enforceActiveBan(RequestEvent $event, string $operation): void { if (!$event->isMainRequest() || !$this->enabledForRequest($event->getRequest()) || !$this->policy->enabled()) { diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index a6709a6a..6c67df7e 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -39,6 +39,11 @@ use Symfony\Component\Security\Csrf\CsrfTokenManager; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Event\LoginFailureEvent; use Twig\Environment; use Twig\Loader\ArrayLoader; @@ -231,6 +236,44 @@ public function testEarlyLoginGuardKeepsMarkedRecoverySubmissionsReachable(): vo self::assertFalse($event->hasResponse()); } + public function testRecoveryLoginFailuresAfterActiveBanReturnBareForbiddenWithoutSideEffects(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $csrfTokens = new CsrfTokenManager(); + $request = Request::create('/user/login', 'POST', [ + 'username' => 'owner', + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => (string) $csrfTokens->getToken(AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_ID), + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'request-recovery-failure-ban'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + $subscriber = $this->subscriber($visitorIds, $store, $clock, csrfTokens: $csrfTokens); + $requestEvent = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $subscriber->onKernelRequestLogin($requestEvent); + + self::assertFalse($requestEvent->hasResponse()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + self::assertSame($ban->key(), $request->attributes->get(AutoBanRequestSubscriber::RECOVERY_ACTIVE_BAN_KEY_ATTRIBUTE)); + + $failure = new LoginFailureEvent( + new AuthenticationException('Invalid credentials.'), + new AutoBanRequestTestAuthenticator(), + $request, + null, + 'main', + ); + $subscriber->onLoginFailure($failure); + + self::assertSame(403, $failure->getResponse()?->getStatusCode()); + self::assertSame('3600', $failure->getResponse()?->headers->get('Retry-After')); + self::assertStringContainsString('request-recovery-failure-ban', (string) $failure->getResponse()?->getContent()); + } + public function testRecoveryLoginSubmissionsCanEstablishTrustedContextDespiteActiveBan(): void { $clock = new MockClock('2026-06-18 12:00:00'); @@ -453,3 +496,31 @@ public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $c return new Response(); } } + +final class AutoBanRequestTestAuthenticator implements AuthenticatorInterface +{ + public function supports(Request $request): ?bool + { + return true; + } + + public function authenticate(Request $request): Passport + { + throw new AuthenticationException('Not used by this test.'); + } + + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + throw new AuthenticationException('Not used by this test.'); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return null; + } +} From 0c5b3fc79d6f86c48565ed93701ce8eff1c1ba7c Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 21:03:36 +0200 Subject: [PATCH 52/55] Bound suspicious payload body scanning --- .../Abuse/SuspiciousRequestPayloadMatcher.php | 43 ++++++++++++++++++- .../SuspiciousPayloadSignalSubscriberTest.php | 20 +++++---- .../SuspiciousRequestPayloadMatcherTest.php | 32 ++++++++++---- 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/src/Security/Abuse/SuspiciousRequestPayloadMatcher.php b/src/Security/Abuse/SuspiciousRequestPayloadMatcher.php index 23c12007..bccca31e 100644 --- a/src/Security/Abuse/SuspiciousRequestPayloadMatcher.php +++ b/src/Security/Abuse/SuspiciousRequestPayloadMatcher.php @@ -76,7 +76,11 @@ private function bodyPayload(Request $request): array return []; } - $content = mb_substr($request->getContent(), 0, self::MAX_BODY_LENGTH); + $content = $this->boundedBody($request, self::MAX_BODY_LENGTH); + if (null === $content) { + return []; + } + if ('' === trim($content)) { return []; } @@ -101,11 +105,46 @@ private function jsonLikeRequest(Request $request): bool return true; } - $content = ltrim(mb_substr($request->getContent(), 0, 32)); + $content = $this->boundedBody($request, 32); + if (null === $content) { + return false; + } + + $content = ltrim($content); return str_starts_with($content, '{') || str_starts_with($content, '['); } + private function boundedBody(Request $request, int $limit): ?string + { + $length = $this->declaredContentLength($request); + if (null === $length || $length > self::MAX_BODY_LENGTH) { + return null; + } + + $content = $request->getContent(); + if (strlen($content) > self::MAX_BODY_LENGTH) { + return null; + } + + return substr($content, 0, max(0, $limit)); + } + + private function declaredContentLength(Request $request): ?int + { + $length = $request->headers->get('Content-Length') ?? $request->server->get('CONTENT_LENGTH'); + if (!is_string($length) && !is_int($length)) { + return null; + } + + $length = trim((string) $length); + if ('' === $length || 1 !== preg_match('/^\d+$/', $length)) { + return null; + } + + return (int) $length; + } + /** * @param array $payload * @param list $signatures diff --git a/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php b/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php index cf0404b2..1d67dd6f 100644 --- a/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php +++ b/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php @@ -85,18 +85,20 @@ public function testItRecordsJsonApiPayloadSignals(): void { $connection = $this->connection(); $visitorIds = new VisitorIdGenerator('test-secret'); + $content = json_encode([ + 'filter' => [ + 'query' => "x' UNION SELECT password FROM users --", + ], + ], JSON_THROW_ON_ERROR); $request = Request::create( '/api/v1/search', 'POST', server: [ 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => (string) strlen($content), 'REMOTE_ADDR' => '203.0.113.10', ], - content: json_encode([ - 'filter' => [ - 'query' => "x' UNION SELECT password FROM users --", - ], - ], JSON_THROW_ON_ERROR), + content: $content, ); $this->subscriber($connection, $visitorIds, new AccessRequestMetadata())->onKernelRequest(new RequestEvent( @@ -158,16 +160,18 @@ public function testItSkipsAdminJsonPayloadsThatMayContainCustomCode(): void { $connection = $this->connection(); $visitorIds = new VisitorIdGenerator('test-secret'); + $content = json_encode([ + 'custom_twig' => '', + ], JSON_THROW_ON_ERROR); $request = Request::create( '/admin/content/schemas', 'POST', server: [ 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => (string) strlen($content), 'REMOTE_ADDR' => '203.0.113.10', ], - content: json_encode([ - 'custom_twig' => '', - ], JSON_THROW_ON_ERROR), + content: $content, ); $this->subscriber($connection, $visitorIds, new AccessRequestMetadata())->onKernelRequest(new RequestEvent( diff --git a/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php b/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php index b913abef..36ddf895 100644 --- a/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php +++ b/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php @@ -44,15 +44,16 @@ public function testItDetectsMalformedSecurityParametersButAllowsOrdinaryArrays( public function testItDetectsJsonBodyAttackSignaturesWithoutReturningRawPayloads(): void { + $content = json_encode([ + 'filter' => [ + 'query' => "x' UNION SELECT password FROM users --", + ], + ], JSON_THROW_ON_ERROR); $match = (new SuspiciousRequestPayloadMatcher())->match(Request::create( '/api/v1/search', 'POST', - server: ['CONTENT_TYPE' => 'application/json'], - content: json_encode([ - 'filter' => [ - 'query' => "x' UNION SELECT password FROM users --", - ], - ], JSON_THROW_ON_ERROR), + server: ['CONTENT_TYPE' => 'application/json', 'CONTENT_LENGTH' => (string) strlen($content)], + content: $content, )); self::assertIsArray($match); @@ -64,11 +65,12 @@ public function testItDetectsJsonBodyAttackSignaturesWithoutReturningRawPayloads public function testItDetectsJsonLikeRawBodyAttackSignatures(): void { + $content = '{"query":"../../etc/passwd"'; $match = (new SuspiciousRequestPayloadMatcher())->match(Request::create( '/api/v1/search', 'POST', - server: ['CONTENT_TYPE' => 'application/json'], - content: '{"query":"../../etc/passwd"', + server: ['CONTENT_TYPE' => 'application/json', 'CONTENT_LENGTH' => (string) strlen($content)], + content: $content, )); self::assertIsArray($match); @@ -77,4 +79,18 @@ public function testItDetectsJsonLikeRawBodyAttackSignatures(): void self::assertSame('body', $match['parameters'][0]['name']); self::assertStringNotContainsString('/etc/passwd', json_encode($match, JSON_THROW_ON_ERROR)); } + + public function testItSkipsOversizedJsonBodiesBeforePayloadScanning(): void + { + $content = '{"query":"../../etc/passwd","padding":"'.str_repeat('x', 9000).'"}'; + + $match = (new SuspiciousRequestPayloadMatcher())->match(Request::create( + '/api/v1/search', + 'POST', + server: ['CONTENT_TYPE' => 'application/json', 'CONTENT_LENGTH' => (string) strlen($content)], + content: $content, + )); + + self::assertNull($match); + } } From 5d05c4a14ad91d2eae7133aed055f129867a072e Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 21:05:59 +0200 Subject: [PATCH 53/55] Score anonymous application payload probes --- .../SuspiciousPayloadSignalSubscriber.php | 7 +-- .../SuspiciousPayloadSignalSubscriberTest.php | 49 +++++++++++++++++-- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/Security/Abuse/SuspiciousPayloadSignalSubscriber.php b/src/Security/Abuse/SuspiciousPayloadSignalSubscriber.php index c2086e66..35e2625a 100644 --- a/src/Security/Abuse/SuspiciousPayloadSignalSubscriber.php +++ b/src/Security/Abuse/SuspiciousPayloadSignalSubscriber.php @@ -50,7 +50,7 @@ public function onKernelRequest(RequestEvent $event): void $inspection = $this->inspector->inspect($request); $subjects = $inspection['subjects']; $profile = $inspection['profile']; - if ($this->safeApplicationInput($profile) || $this->trustedContext($subjects) || $this->trustedSchedulerCredential($request, $profile)) { + if ($this->safeApplicationInput($profile, $subjects) || $this->trustedContext($subjects) || $this->trustedSchedulerCredential($request, $profile)) { return; } @@ -109,9 +109,10 @@ private function sourceSubjects(AbuseSubjectResolution $subjects): array ])); } - private function safeApplicationInput(AbuseRequestProfile $profile): bool + private function safeApplicationInput(AbuseRequestProfile $profile, AbuseSubjectResolution $subjects): bool { - return in_array($profile->family(), [RequestFamily::Admin, RequestFamily::Editor, RequestFamily::Setup], true); + return in_array($profile->family(), [RequestFamily::Admin, RequestFamily::Editor, RequestFamily::Setup], true) + && $subjects->first(AbuseSubjectType::User) instanceof AbuseSubject; } private function trustedContext(AbuseSubjectResolution $subjects): bool diff --git a/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php b/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php index 1d67dd6f..940f6d8b 100644 --- a/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php +++ b/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php @@ -7,6 +7,7 @@ use App\Core\Log\AccessRequestMetadata; 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; @@ -15,6 +16,7 @@ use App\Security\Abuse\SuspiciousPayloadSignalSubscriber; use App\Security\Abuse\SuspiciousRequestPayloadMatcher; use App\Security\AutoBan\AutoBanRequestSubscriber; +use App\Security\UserRole; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; @@ -23,6 +25,7 @@ use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; final class SuspiciousPayloadSignalSubscriberTest extends TestCase { @@ -147,7 +150,7 @@ public function testItSkipsAdminEditorPayloadsThatMayContainCustomCode(): void 'REMOTE_ADDR' => '203.0.113.10', ]); - $this->subscriber($connection, $visitorIds, new AccessRequestMetadata())->onKernelRequest(new RequestEvent( + $this->subscriber($connection, $visitorIds, new AccessRequestMetadata(), $this->tokenStorageWithManager())->onKernelRequest(new RequestEvent( new SuspiciousPayloadSignalTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST, @@ -174,7 +177,7 @@ public function testItSkipsAdminJsonPayloadsThatMayContainCustomCode(): void content: $content, ); - $this->subscriber($connection, $visitorIds, new AccessRequestMetadata())->onKernelRequest(new RequestEvent( + $this->subscriber($connection, $visitorIds, new AccessRequestMetadata(), $this->tokenStorageWithManager())->onKernelRequest(new RequestEvent( new SuspiciousPayloadSignalTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST, @@ -183,12 +186,41 @@ public function testItSkipsAdminJsonPayloadsThatMayContainCustomCode(): void self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); } - private function subscriber(Connection $connection, VisitorIdGenerator $visitorIds, AccessRequestMetadata $metadata): SuspiciousPayloadSignalSubscriber + public function testItScoresUnauthenticatedApplicationSurfacePayloadProbes(): void + { + $connection = $this->connection(); + $visitorIds = new VisitorIdGenerator('test-secret'); + $subscriber = $this->subscriber($connection, $visitorIds, new AccessRequestMetadata()); + + foreach (['/admin', '/editor', '/setup'] as $path) { + $request = Request::create($path, 'GET', [ + 'file' => '../../etc/passwd', + ], server: [ + 'REMOTE_ADDR' => '203.0.113.10', + ]); + + $subscriber->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + } + + self::assertSame(6, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + self::assertSame(6, (int) $connection->fetchOne("SELECT COUNT(*) FROM security_signal_event WHERE reason_code = 'security.signal.suspicious_payload'")); + } + + private function subscriber( + Connection $connection, + VisitorIdGenerator $visitorIds, + AccessRequestMetadata $metadata, + ?TokenStorage $tokenStorage = null, + ): SuspiciousPayloadSignalSubscriber { return new SuspiciousPayloadSignalSubscriber( new SuspiciousRequestPayloadMatcher(), new AbuseRequestInspector( - new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new AbuseSubjectResolver($visitorIds, $tokenStorage ?? new TokenStorage(), 'test-secret'), new RequestIntentClassifier(), new ActionCostCatalogue(), ), @@ -197,6 +229,15 @@ private function subscriber(Connection $connection, VisitorIdGenerator $visitorI ); } + private function tokenStorageWithManager(): TokenStorage + { + $user = new UserAccount('99999999-0000-7000-8000-000000000201', 'manager', 'manager@example.test', 'hash', role: UserRole::Manager); + $storage = new TokenStorage(); + $storage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + + return $storage; + } + private function connection(): Connection { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); From 31ab2ae85d84a10ad52f97022a2385dc3600c75b Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 21:10:45 +0200 Subject: [PATCH 54/55] Document auto-ban review hardening --- dev/CLASSMAP.md | 6 +++--- dev/WORKLOG.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 0b94564e..483e7911 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -1,7 +1,7 @@ # Developer Class Map > **Status**: Active -> **Updated**: 2026-06-17 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** This document tracks callable entry points (services, commands, controllers, Twig components, Stimulus controllers). Keep it up to date as new classes are added or interfaces change. This document is meant to evolve alongside the codebase—treat it as a living index for developers to quickly discover callables without grepping through the project. @@ -200,9 +200,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 high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | -| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted query, form, and bounded JSON/raw-body attack-pattern payload detection while avoiding non-existent contact/captcha path assumptions and skipping Admin/Editor/Setup/trusted-user code-bearing forms plus valid trusted-user-owned scheduler credentials, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | +| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted query, form, and Content-Length-bounded JSON/raw-body attack-pattern payload detection that scores anonymous Admin/Editor/Setup payload probes while avoiding non-existent contact/captcha path assumptions and skipping authenticated Admin/Editor/Setup/trusted-user code-bearing forms plus valid trusted-user-owned scheduler credentials, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS` unless an active auto-ban response already owns the request, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | -| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**` and rechecks after request-phase signal writes so newly created bans stop before controllers, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders bypass active bans, lets CSRF-marked recovery submissions reach authentication, and rechecks those submissions so only trusted contexts remain unblocked, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**` and rechecks after request-phase signal writes so newly created bans stop before controllers, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders bypass active bans, lets CSRF-marked recovery submissions reach authentication while marking active-banned recovery attempts to suppress auth-failure side effects, returns bare `403` on recovery login failures, and rechecks successful submissions so only trusted contexts remain unblocked, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Log/DatabaseLogRetentionPolicyTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 09c411f4..5753144d 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -113,6 +113,7 @@ - Extended the config validation guard to other small bounded runtime settings that already had form validation: user menu sort order, account-link TTL hours, deleted-user retention days, and the auto-ban score threshold now normalize already-persisted out-of-range values to their effective runtime bounds. - Addressed the latest Cloud Review round with separate reviewable commits: CSRF-marked recovery login submissions now only pass the post-auth active-ban recheck when authentication established a trusted user context, and valid trusted-user-owned scheduler credentials skip request-phase suspicious payload source scoring for both Bearer and enabled `?auth=` scheduler calls. - Addressed the next Cloud Review round with separate reviewable commits: trusted auto-ban access level is bounded to valid registered-user levels `USER..OWNER` while keeping the Owner-selectable policy range, request-phase payload signal writes are followed by a second active-ban guard so newly created bans stop before controllers, and payload matching now scans bounded JSON/raw JSON-like body metadata under the same public/untrusted context guards without storing raw submitted values. +- Addressed the next Cloud Review round with separate reviewable commits: active-banned recovery-login failures now return the bare auto-ban `403` and suppress auth-failure signal/rate-limit side effects, suspicious JSON/raw-body scanning now requires bounded `Content-Length` before reading request bodies, and anonymous Admin/Editor/Setup payload probes are scored while authenticated code-bearing application forms remain exempt. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). From c8372f7b3eb215a67c6be92055fb3da2cab18090 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Thu, 18 Jun 2026 21:51:39 +0200 Subject: [PATCH 55/55] Block protected browser auto-ban bypasses --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + .../AutoBan/AutoBanRequestSubscriber.php | 116 ++++++++++++++++- src/Security/HttpErrorSecurityHandler.php | 15 ++- .../AutoBan/AutoBanRequestSubscriberTest.php | 123 +++++++++++++++++- 5 files changed, 246 insertions(+), 11 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 483e7911..a0923a67 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -202,7 +202,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, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | | Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad package/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted query, form, and Content-Length-bounded JSON/raw-body attack-pattern payload detection that scores anonymous Admin/Editor/Setup payload probes while avoiding non-existent contact/captcha path assumptions and skipping authenticated Admin/Editor/Setup/trusted-user code-bearing forms plus valid trusted-user-owned scheduler credentials, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | | Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before package loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS` unless an active auto-ban response already owns the request, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | -| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**` and rechecks after request-phase signal writes so newly created bans stop before controllers, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders bypass active bans, lets CSRF-marked recovery submissions reach authentication while marking active-banned recovery attempts to suppress auth-failure side effects, returns bare `403` on recovery login failures, and rechecks successful submissions so only trusted contexts remain unblocked, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, blocks sessionless protected browser surfaces such as Admin, Editor, and protected User account routes before firewall access-control responses, rechecks previous-session protected-browser entry/access-denied handling so non-trusted users remain blocked while trusted contexts bypass, overrides later `400`/`401`/`403`/`404`/`429` error responses before passive signal scoring for dynamic public/content views, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**` and rechecks after request-phase signal writes so newly created bans stop before controllers, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders bypass active bans, lets CSRF-marked recovery submissions reach authentication while marking active-banned recovery attempts to suppress auth-failure side effects, returns bare `403` on recovery login failures, and rechecks successful submissions so only trusted contexts remain unblocked, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | | Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Log/DatabaseLogRetentionPolicyTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | | Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | | Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 5753144d..ec2bd0d0 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -114,6 +114,7 @@ - Addressed the latest Cloud Review round with separate reviewable commits: CSRF-marked recovery login submissions now only pass the post-auth active-ban recheck when authentication established a trusted user context, and valid trusted-user-owned scheduler credentials skip request-phase suspicious payload source scoring for both Bearer and enabled `?auth=` scheduler calls. - Addressed the next Cloud Review round with separate reviewable commits: trusted auto-ban access level is bounded to valid registered-user levels `USER..OWNER` while keeping the Owner-selectable policy range, request-phase payload signal writes are followed by a second active-ban guard so newly created bans stop before controllers, and payload matching now scans bounded JSON/raw JSON-like body metadata under the same public/untrusted context guards without storing raw submitted values. - Addressed the next Cloud Review round with separate reviewable commits: active-banned recovery-login failures now return the bare auto-ban `403` and suppress auth-failure signal/rate-limit side effects, suspicious JSON/raw-body scanning now requires bounded `Content-Length` before reading request bodies, and anonymous Admin/Editor/Setup payload probes are scored while authenticated code-bearing application forms remain exempt. +- Addressed the next Cloud Review browser-surface ordering issue: sessionless active-banned sources now receive the bare auto-ban `403` on protected Admin, Editor, and User account routes before firewall access-control responses; previous-session protected-browser requests are rechecked through the security entry/access-denied handler so only trusted user contexts bypass the source ban. A response-phase fallback also overrides later `400`/`401`/`403`/`404`/`429` error responses before passive signal scoring, covering future dynamic public/content views whose routes are not known to the pre-auth prefix guard. The scheduler read-only-key bypass finding was assessed as invalid because auto-ban only decides whether a provided credential belongs to a trusted user; scheduler authorization remains owned by the scheduler authenticator/controller. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/src/Security/AutoBan/AutoBanRequestSubscriber.php b/src/Security/AutoBan/AutoBanRequestSubscriber.php index 744c8479..9f1df3be 100644 --- a/src/Security/AutoBan/AutoBanRequestSubscriber.php +++ b/src/Security/AutoBan/AutoBanRequestSubscriber.php @@ -25,6 +25,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; @@ -68,9 +69,11 @@ public static function getSubscribedEvents(): array ['onKernelRequestPreAuthSourceBan', 4098], ['onKernelRequestProbeCandidate', 4097], ['onKernelRequestLogin', 16], + ['onKernelRequestPreAuthBrowserSourceBan', 9], ['onKernelRequest', 4], ['onKernelRequestAfterSignalWrites', 1], ], + KernelEvents::RESPONSE => ['onKernelResponseErrorStatus', -299], LoginFailureEvent::class => ['onLoginFailure', 64], ]; } @@ -169,6 +172,38 @@ public function onKernelRequestLogin(RequestEvent $event): void } } + public function onKernelRequestPreAuthBrowserSourceBan(RequestEvent $event): void + { + $request = $event->getRequest(); + if ( + !$event->isMainRequest() + || $event->hasResponse() + || !$this->enabledForRequest($request) + || !$this->policy->enabled() + || !$this->preAuthProtectedBrowserSurface($request) + || $request->hasPreviousSession() + ) { + return; + } + + try { + $ban = $this->activeBanFor($this->inspector->inspect($request)['subjects']); + if (!$ban instanceof ActiveAutoBan) { + return; + } + + $event->setResponse($this->banResponse($request, $ban)); + $request->attributes->set(self::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + } catch (Throwable $error) { + $this->reportEvaluation($error, [ + 'operation' => 'browser_source_ban', + 'path' => $request->getPathInfo(), + ]); + + return; + } + } + public function onKernelRequest(RequestEvent $event): void { $this->enforceActiveBan($event, 'request_enforcement'); @@ -179,6 +214,30 @@ public function onKernelRequestAfterSignalWrites(RequestEvent $event): void $this->enforceActiveBan($event, 'post_signal_request_enforcement'); } + public function onKernelResponseErrorStatus(ResponseEvent $event): void + { + $request = $event->getRequest(); + $response = $event->getResponse(); + if ( + !$event->isMainRequest() + || !in_array($response->getStatusCode(), [ + Response::HTTP_BAD_REQUEST, + Response::HTTP_UNAUTHORIZED, + Response::HTTP_FORBIDDEN, + Response::HTTP_NOT_FOUND, + Response::HTTP_TOO_MANY_REQUESTS, + ], true) + || $request->attributes->getBoolean(self::PASSIVE_SIGNAL_SKIP_ATTRIBUTE) + ) { + return; + } + + $banResponse = $this->activeBanResponseForRequest($request, 'error_response_enforcement'); + if ($banResponse instanceof Response) { + $event->setResponse($banResponse); + } + } + public function onLoginFailure(LoginFailureEvent $event): void { $request = $event->getRequest(); @@ -200,6 +259,11 @@ public function onLoginFailure(LoginFailureEvent $event): void $request->attributes->set(self::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); } + public function responseForSecurityHandler(Request $request): ?Response + { + return $this->activeBanResponseForRequest($request, 'security_handler_enforcement'); + } + private function enforceActiveBan(RequestEvent $event, string $operation): void { if (!$event->isMainRequest() || !$this->enabledForRequest($event->getRequest()) || !$this->policy->enabled()) { @@ -207,20 +271,33 @@ private function enforceActiveBan(RequestEvent $event, string $operation): void } $request = $event->getRequest(); + $response = $this->activeBanResponseForRequest($request, $operation); + if ($response instanceof Response) { + $event->setResponse($response); + } + } + + private function activeBanResponseForRequest(Request $request, string $operation): ?Response + { + if (!$this->enabledForRequest($request) || !$this->policy->enabled()) { + return null; + } + if ($request->attributes->getBoolean(self::TRUSTED_PRE_AUTH_BYPASS_ATTRIBUTE)) { - return; + return null; } try { $inspection = $this->inspector->inspect($request); if ($this->recoveryRenderRequest($inspection['profile']) || $this->trustedContext($inspection['subjects']->subjects())) { - return; + return null; } $ban = $this->activeBanFor($inspection['subjects']); if ($ban instanceof ActiveAutoBan) { - $event->setResponse($this->banResponse($request, $ban)); $request->attributes->set(self::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + + return $this->banResponse($request, $ban); } } catch (Throwable $error) { $this->reportEvaluation($error, [ @@ -228,8 +305,10 @@ private function enforceActiveBan(RequestEvent $event, string $operation): void 'path' => $request->getPathInfo(), ]); - return; + return null; } + + return null; } /** @@ -324,6 +403,35 @@ private function preAuthProtectedSurface(Request $request): ?string return ['cron', 'run'] === $segments ? 'scheduler' : null; } + private function preAuthProtectedBrowserSurface(Request $request): bool + { + $segments = $this->paths->segments($request); + $first = $segments[0] ?? null; + if (in_array($first, ['admin', 'editor'], true)) { + return true; + } + + return ['user'] === $segments + || $this->matchesSegments($segments, 'user', 'api-keys') + || $this->matchesSegments($segments, 'user', 'profile') + || $this->matchesSegments($segments, 'user', 'password') + || $this->matchesSegments($segments, 'user', 'logout'); + } + + /** + * @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; + } + private function banResponse(Request $request, ActiveAutoBan $ban): Response { $retryAfter = $ban->retryAfterSeconds($this->clock->now()); diff --git a/src/Security/HttpErrorSecurityHandler.php b/src/Security/HttpErrorSecurityHandler.php index 0c9a6592..655dbf35 100644 --- a/src/Security/HttpErrorSecurityHandler.php +++ b/src/Security/HttpErrorSecurityHandler.php @@ -4,6 +4,7 @@ namespace App\Security; +use App\Security\AutoBan\AutoBanRequestSubscriber; use App\View\Http\HttpErrorRenderer; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -14,17 +15,27 @@ final readonly class HttpErrorSecurityHandler implements AuthenticationEntryPointInterface, AccessDeniedHandlerInterface { - public function __construct(private HttpErrorRenderer $httpError) - { + public function __construct( + private HttpErrorRenderer $httpError, + private ?AutoBanRequestSubscriber $autoBan = null, + ) { } public function start(Request $request, ?AuthenticationException $authException = null): Response { + if (null !== ($response = $this->autoBan?->responseForSecurityHandler($request))) { + return $response; + } + return $this->httpError->unauthorized($request, $authException); } public function handle(Request $request, AccessDeniedException $accessDeniedException): Response { + if (null !== ($response = $this->autoBan?->responseForSecurityHandler($request))) { + return $response; + } + return $this->httpError->unauthorized($request, $accessDeniedException); } } diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php index 6c67df7e..40950646 100644 --- a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -14,12 +14,14 @@ use App\Security\Abuse\AbuseRequestInspector; use App\Security\Abuse\AbuseSubjectResolver; use App\Security\Abuse\ActionCostCatalogue; +use App\Security\Abuse\PassiveAbuseSignalSubscriber; use App\Security\Abuse\RequestIntentClassifier; use App\Security\Abuse\SuspiciousPayloadSignalSubscriber; use App\Security\AutoBan\AutoBanPolicy; use App\Security\AutoBan\AutoBanRequestSubscriber; use App\Security\AutoBan\AutoBanStore; use App\Security\AutoBan\AutoBanSubject; +use App\Security\HttpErrorSecurityHandler; use App\Security\RateLimit\RateLimitRequestSubscriber; use App\Security\UserRole; use App\Setup\SetupCompletionMarker; @@ -31,7 +33,10 @@ use Symfony\Component\Clock\MockClock; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Lock\LockFactory; @@ -40,6 +45,7 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; @@ -183,6 +189,109 @@ public function testSchedulerTriggersWithoutTrustedKeyDoNotBypassActiveBans(): v self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); } + public function testProtectedBrowserSurfacesWithoutPreviousSessionAreBlockedBeforeFirewallAccessControl(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + + foreach (['/admin', '/editor/content', '/user/profile'] as $path) { + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create($path, server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthBrowserSourceBan($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode(), $path); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE), $path); + } + } + + public function testProtectedBrowserSurfacesWithPreviousSessionWaitForTrustedAwareGuard(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']); + $session = new Session(new MockArraySessionStorage()); + $request->setSession($session); + $request->cookies->set($session->getName(), 'previous-session-id'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthBrowserSourceBan($event); + + self::assertFalse($event->hasResponse()); + self::assertFalse($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testSecurityHandlerBlocksPreviousSessionNonTrustedBrowserAccess(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']); + $session = new Session(new MockArraySessionStorage()); + $request->setSession($session); + $request->cookies->set($session->getName(), 'previous-session-id'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000103', 'member', 'member@example.test', 'hash', role: UserRole::User); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $subscriber = $this->subscriber($visitorIds, $store, $clock, $tokenStorage); + $handler = new HttpErrorSecurityHandler($this->renderer(), $subscriber); + + $response = $handler->handle($request, new AccessDeniedException('Access denied.')); + + self::assertSame(403, $response->getStatusCode()); + self::assertSame('3600', $response->headers->get('Retry-After')); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testSecurityHandlerKeepsTrustedBrowserAccessOutsideAutoBanEnforcement(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000104', 'manager', 'manager@example.test', 'hash', role: UserRole::Manager); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $subscriber = $this->subscriber($visitorIds, $store, $clock, $tokenStorage); + + self::assertNull($subscriber->responseForSecurityHandler($request)); + } + + public function testErrorResponsesAreOverriddenBeforePassiveSignalScoring(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + + foreach ([400, 401, 403, 404, 429] as $statusCode) { + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/member-only-page', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new ResponseEvent( + new AutoBanRequestTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + new Response('error response', $statusCode), + ); + + $this->subscriber($visitorIds, $store, $clock)->onKernelResponseErrorStatus($event); + + self::assertSame(403, $event->getResponse()->getStatusCode(), (string) $statusCode); + self::assertSame('3600', $event->getResponse()->headers->get('Retry-After'), (string) $statusCode); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE), (string) $statusCode); + } + } + public function testRecoveryLoginBypassIsReachableDespiteActiveBan(): void { $clock = new MockClock('2026-06-18 12:00:00'); @@ -431,20 +540,26 @@ public function testTrustedUsersBypassActiveVisitorBans(): void public function testSubscriberRunsAfterSecurityContextButBeforeOrdinaryRateLimit(): void { $autoBan = AutoBanRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + $autoBanResponse = AutoBanRequestSubscriber::getSubscribedEvents()[KernelEvents::RESPONSE]; $rateLimit = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; $payloadSignals = SuspiciousPayloadSignalSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + $passiveSignals = PassiveAbuseSignalSubscriber::getSubscribedEvents()[KernelEvents::RESPONSE]; self::assertSame(['onKernelRequestPreAuthSourceBan', 4098], $autoBan[0]); self::assertSame(['onKernelRequestProbeCandidate', 4097], $autoBan[1]); self::assertSame(['onKernelRequestLogin', 16], $autoBan[2]); - self::assertSame(['onKernelRequest', 4], $autoBan[3]); - self::assertSame(['onKernelRequestAfterSignalWrites', 1], $autoBan[4]); + self::assertSame(['onKernelRequestPreAuthBrowserSourceBan', 9], $autoBan[3]); + self::assertSame(['onKernelRequest', 4], $autoBan[4]); + self::assertSame(['onKernelRequestAfterSignalWrites', 1], $autoBan[5]); self::assertGreaterThan($rateLimit[0][1], $autoBan[0][1]); self::assertGreaterThan($rateLimit[0][1], $autoBan[1][1]); + self::assertGreaterThan(8, $autoBan[3][1]); self::assertSame(['onKernelRequestOrdinary', 3], $rateLimit[1]); self::assertGreaterThan($rateLimit[1][1], $autoBan[3][1]); - self::assertGreaterThan($autoBan[4][1], $payloadSignals[1]); - self::assertLessThan($autoBan[3][1], $payloadSignals[1]); + self::assertGreaterThan($autoBan[5][1], $payloadSignals[1]); + self::assertLessThan($autoBan[4][1], $payloadSignals[1]); + self::assertSame(['onKernelResponseErrorStatus', -299], $autoBanResponse); + self::assertGreaterThan($passiveSignals[1], $autoBanResponse[1]); } private function subscriber(