Opt-in application-layer security middleware for the API: attack screening, bans, per-route policies (psr15-guard)

Aperta
#3,835 12 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
5/5
Tempo stimato
Più di una settimana
Idoneità per principianti
15/100
Tipo di issue
Funzionalità
Chiarezza
Abbastanza chiara
Stato di attività
Attiva
Stack tecnologico
php
Ambito
api, backend, security

Direzione di ricerca

Start with composer.json, application/Espo/Resources/metadata/app/api.json, MiddlewareProvider.php, and Starter.php to trace dependency constraints and middleware order; then read the configuration schema and existing middleware tests. The proposal leaves key policy, configuration, compatibility, and false-positive decisions open, so first determine whether maintainers accept the integration and its defaults. Done means an approved design, tested opt-in behavior, and coverage for enabled and disabled requests.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

What I am proposing

EspoCRM's API already runs on Slim 4.15 (composer.json:23, locked at 4.15.2) and already has a global middleware hook: app/api.json -> globalMiddlewareClassNameList (application/Espo/Resources/metadata/app/api.json:2, read by MiddlewareProvider.php:111, applied in Starter::addGlobalMiddlewares() at Starter.php:90-95). The schema describes it as "Global middlewares applied for all API requests. Processed before authentication" (schema/metadata/app/api.json:8-13). That hook is empty by default.

I maintain psr15-guard (MIT, Packagist rennf93/psr15-guard, current release v1.6.1), the PSR-15 adapter of Guard Core, an application-layer security engine for PHP (rennf93/guard-core-php, v4.4.0). Because it is a plain PSR-15 middleware, it fits that hook without touching Slim itself. (slim-guard is the Slim convenience wrapper over the same middleware; it is not what I would use here, see the wiring section.) I would like to discuss shipping an opt-in, off-by-default integration: one config param that, when on, registers one EspoCRM middleware class that builds and runs psr15-guard's GuardMiddleware. With the param off (the default) nothing changes and nothing is loaded.

Rate limiting is one feature of the engine, not the point of it. What the engine does for an application like EspoCRM:

  • Attack-pattern screening of paths, query strings, headers and request bodies, with semantic analysis across 19 detection categories (SQL injection, XSS, path traversal, command injection, SSRF, NoSQL injection, LDAP injection, deserialization, file inclusion and others; src/Support/Generated/PatternData.php and docs/configuration.md:167 in guard-core-php v4.4.0). A request that matches is answered with a 400. The engine also has a per-process auto-ban after repeated matches (SuspiciousActivityCheck.php:200-218), but that counter is not shared across requests under PHP-FPM, so I do not count on it; see the caveat under "Things I want to be upfront about".
  • Per-route policies, attached to a route key such as POST /api/v1/LeadCapture/: IP allow and deny lists, country allow and deny lists, cloud-provider blocking, user-agent rules, required headers (400 when missing), request size limit (413, checked against Content-Length) and allowed content types, rate limits, and detection exclusions (Routing/RouteConfig.php:118-150). A key is an optional method prefix plus a path: the path matches exactly, or as a prefix when it ends with /, and a method-scoped key wins a same-path tie (psr15-guard src/GuardMiddleware.php:37-43, :318-326, :359-362).
  • Behavior rules: ban, log, throttle or alert after repeated responses with a given status on a route (return_pattern rules with a status:401, status:403 or status:404 pattern, which covers repeated 401 or 403 responses and 404 scanning), or after too many requests in a window (usage and frequency rules). Rule types and actions are in guard-core-php src/Behavior/BehaviorRule.php:15-17, the status pattern in src/Behavior/BehaviorTracker.php:194.
  • Honeypot trap fields for public forms (a filled trap field on a POST, PUT or PATCH form or JSON body gets a 403; src/Decorators/SecurityDecorator.php:641-705), emergency mode (every client outside an allowlist gets a 503; EmergencyModeCheck.php:29-51), passive mode (log what would be blocked without blocking), trusted-proxy aware client identity (an X-Forwarded-For chain is only believed from a trusted peer, and a chain from an untrusted peer is ignored and reported as a spoof attempt; src/Request/ClientIpResolver.php:15-23, :263-272), and optional dynamic rules and security telemetry through an agent (SecurityConfig.php:368).

It complements whatever sits at the edge (nginx, a WAF, Cloudflare): it runs inside the application, sees the authenticated route and the real verdict of the request, and works the same on a shared host with no edge layer at all.

Where it would help in EspoCRM (read from the code)

  • Login and API credentials. Login is handled by Authentication::login() (Core/Authentication/Authentication.php:102), reached from Core\Api\Auth::process() for every authenticated route, with methods Espo, ApiKey (X-Api-Key, Logins/ApiKey.php:57) and Hmac (X-Hmac-Authorization, Logins/Hmac.php:56).
  • Unauthenticated routes ("noAuth": true in Resources/routes.json): /I18n and /Settings (GET, lines 36-52), /LeadCapture/form/:id and /LeadCapture/:apiKey (POST and OPTIONS, lines 91-114), /User/passwordChangeRequest and /User/changePasswordByRequest (lines 497-507), /Oidc/authorizationData and /Oidc/backchannelLogout (lines 529-546). Lead capture and the password routes are internet-facing write endpoints.
  • Uploads: POST /Attachment/chunk/:id (routes.json:329-334, Tools/Attachment/Api/PostChunk.php), with a default chunk size of 4 MB and max size 256 MB (Resources/defaults/config.php:255-256).
  • CRM data: records, emails, notes and attachments are free text, so an SQL-looking or HTML-looking string is normal content. That is the main false-positive risk, and it is why I propose passive mode first and route-level exclusions for upload and rich-text fields.

What EspoCRM already does, and what I would not replace

I read the existing throttling; the guard would sit next to it, not in place of it.

  • Before-login hooks are registered in Resources/metadata/app/authentication.json:2-6 and run from Authentication::login() at Authentication.php:112.
  • Per-IP failed-login limit (Hook/Hooks/FailedAttemptsLimit.php): counts AuthLogRecord rows with isDenied for the client IP within the last authFailedAttemptsPeriod (default 60 seconds, ConfigDataProvider.php:39); once the count is above authMaxFailedAttemptNumber (default 10, :41; check at FailedAttemptsLimit.php:94), the next login attempt gets a 403 "Max failed login attempts exceeded for IP address ..." (:98).
  • Per-username delay (Hook/Hooks/UsernameFailedAttemptsLimit.php): off unless authUsernameFailedAttemptsLimitEnabled; when on, after authMaxUsernameFailedAttemptNumber (default 30) denied attempts for that username in 60 seconds, a delay of 2 seconds is added (:115), skipped for an IP that has logged in successfully as that user before (:100-113).
  • 2FA code limit (Hook/Hooks/FailedCodeAttemptsLimit.php): per username, default 5 minutes and more than 10 wrong codes, 403 (:103).
  • Optional login IP whitelist (authIpAddressCheck, Hook/Hooks/IpAddressWhitelist.php).
  • All of it is database-backed (AuthLogRecord), applies to login attempts only, is switched off entirely by authLogDisabled, and identifies the client by the server param named in ipAddressServerParam (default REMOTE_ADDR, Core/Api/Util.php:77-82). I found no trusted-proxy logic in the repo, and no rate limit on any other route (a search for rate limit and throttle terms in Core, Tools, Controllers and the defaults finds none), including LeadCapture. Lead capture forms do have an optional captcha (CaptureService.php:108-110).

So the gap the guard would address is everything outside the login attempt itself: hostile payloads on any route, clients that are denied or probing across many routes (not only /App/user), an unauthenticated lead-capture or password-reset endpoint being hammered, and, through behavior rules on a Redis-backed tracker, a ban that outlives one request window and is shared across PHP workers (BehaviorTracker.php:66-68).

How I would wire it (proposal, not a finished design)

  • One param, for example guardEnabled (default false), read from data/config.php. The params that are not editable in the admin UI are listed in Resources/defaults/systemConfig.php (systemItems, from line 47; authFailedAttemptsPeriod and ipAddressServerParam are examples at lines 109 and 116), which is where I would add it. Guard settings (mode, thresholds, Redis URL, trusted proxies, the per-route policies) would be a nested config block in the same file.
  • One middleware class, listed in app/api.json -> globalMiddlewareClassNameList, that returns the request unchanged when the param is off and otherwise builds psr15-guard's GuardMiddleware from the config block and delegates to it. SlimGuard itself cannot take a route map (its constructor takes no routes or routeResolver, slim-guard v1.6.0 src/SlimGuard.php:58-77), while GuardMiddleware takes both (src/GuardMiddleware.php:57-66), so building GuardMiddleware directly is what makes route keys like POST /api/v1/LeadCapture/ possible. Because the metadata hook already exists and instantiates the class through the injectable factory (MiddlewareProvider.php:118-123), this needs no change to Starter.php, and could even ship as an extension.
  • Order: Slim runs the last-added middleware first. Starter::start() adds global middleware at Starter.php:79, then the routing middleware at :80, then the error middleware at :82, so the guard runs after routing and before the route handler and EspoCRM's own authentication (the schema text says "before authentication"). EspoCRM does not use Slim's BodyParsingMiddleware (no reference in application/), so the body-parsing ordering caveat of the Slim adapter does not apply.
  • Shared state: bans and rate budgets need Redis to survive across PHP-FPM requests (the in-memory fallback is per request). EspoCRM already has an optional Redis setting (redis in systemConfig.php:125, used by the event transport); I would take a Redis URL for the guard as its own param rather than assume reuse.
  • Client identity: trusted proxies are configured explicitly on the guard (the forwarded chain is only believed from listed proxies). It would need to agree with ipAddressServerParam, or the two would see different client IPs.
  • Entry points: the API (public/api/v1/index.php) and the portal API (public/api/v1/portal-access/index.php) both run Api::class and therefore Starter::start(), so one registration covers both. The non-Slim entry points (public/index.php ?entryPoint=... such as Download, Image, LeadCaptureForm, and public/oauth/index.php) do not go through that stack, so they would not be covered by this wiring.
  • Suggested default policy once enabled: passive mode first (log, do not block), screening on, no body scan on POST /api/v1/Attachment/chunk/, X-Api-Key, X-Hmac-Authorization and Authorization kept out of the logs, a behavior rule that bans a client after repeated 401 responses on GET /api/v1/App/user and on the API prefix, a rate limit on POST /api/v1/LeadCapture/ and the two password routes. A honeypot trap field on the public lead capture form would need a hidden field rendered by FormService, so I would leave that decision to you.
  • Tests: the repo's own tools, vendor/bin/phpunit tests/unit and vendor/bin/phpstan (.github/workflows/test.yml), plus an integration test that sends a hostile and a normal request through Starter with the param on and off.

Things I want to be upfront about

  • Compatibility is real but I have only checked the dependency side: in a clean Composer project, rennf93/psr15-guard v1.6.1 installs next to slim/slim ^4.15 (4.15.3), slim/psr7 ^1, guzzlehttp/psr7 ^2.13, psr/http-message ^2.0, psr/container ^1.1 and psr/log ~3, which are the constraints in EspoCRM's composer.json. It needs PHP 8.2 or later (EspoCRM needs 8.3 to 8.5). It adds rennf93/guard-core-php (no composer dependencies of its own) and the PSR interface packages. Adding a package to composer.json is your call.
  • Body handling: the adapter reads at most the first 256 KiB of a request body for screening (MAX_BODY_BYTES = 262144 in psr15-guard src/PsrGuardRequest.php), so payloads beyond that prefix are not scanned. The body stream is handed back to the handler intact after screening, so a handler that reads it the way RequestWrapper::getBodyContents() does (getContents(), Core/Api/RequestWrapper.php:166-173) gets the full body. I found a stream-position bug here while reading EspoCRM and fixed it in psr15-guard v1.6.1.
  • Bans from payload screening are counted in process memory (SuspiciousActivityCheck.php:437-448; guard-core-php AGENTS.md lists "in-process auto-ban violation counters" as a known gap). Under PHP-FPM that counter does not accumulate across requests, so a ban from repeated hostile payloads alone would need an auto-ban threshold of 1 or a long-lived worker. Bans from behavior rules use the Redis-backed tracker (BehaviorTracker.php:66-68), so those are the ones that persist across workers.
  • False positives on CRM free text are the main operational risk. That is why the proposal starts in passive mode and has route-level exclusions.
  • I have not run this against a live EspoCRM instance yet. Everything above about EspoCRM is read from the source; everything about the guard is from its source and tests.

Questions for you

  1. Would you consider an opt-in, default-off integration like this at all, in core or only as an extension?
  2. If yes, which of the pieces matter to you most (payload screening, behavior-based bans, per-route policies for the unauthenticated routes), so that I can scope a first PR to something small and reviewable?
  3. I would sign the CLA before any PR.

Disclosure: I maintain psr15-guard, slim-guard and guard-core-php, the engine underneath them (MIT). I used AI assistance to prepare this and reviewed and verified everything myself.

Project background: Guard Core's first library is fastapi-guard, public since August 2024 (first PyPI release, 0.1.0, on 2024-08-26). It has 825 GitHub stars, 65 releases and about 62,000 PyPI downloads in the last month (pypistats.org). The framework-agnostic engines and the adapters for other frameworks and languages came later and run the same detection rules, checked in CI against shared conformance fixtures.

Lingua principale
PHP
Stelle
3.4k
Fork
989
Merge medio
13h 48m
PR unite (30g)
8

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di espocrm/espocrm

Tutte le issue di espocrm/espocrm

Issue simili

Altre issue su PHP

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.