Opt-in application-layer security middleware for the API: attack screening, bans, per-route policies (psr15-guard)
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
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.phpanddocs/configuration.md:167in 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 againstContent-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-guardsrc/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_patternrules with astatus:401,status:403orstatus:404pattern, which covers repeated 401 or 403 responses and 404 scanning), or after too many requests in a window (usageandfrequencyrules). Rule types and actions are in guard-core-phpsrc/Behavior/BehaviorRule.php:15-17, the status pattern insrc/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 (anX-Forwarded-Forchain 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 fromCore\Api\Auth::process()for every authenticated route, with methodsEspo,ApiKey(X-Api-Key,Logins/ApiKey.php:57) andHmac(X-Hmac-Authorization,Logins/Hmac.php:56). - Unauthenticated routes (
"noAuth": trueinResources/routes.json):/I18nand/Settings(GET, lines 36-52),/LeadCapture/form/:idand/LeadCapture/:apiKey(POST and OPTIONS, lines 91-114),/User/passwordChangeRequestand/User/changePasswordByRequest(lines 497-507),/Oidc/authorizationDataand/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-6and run fromAuthentication::login()atAuthentication.php:112. - Per-IP failed-login limit (
Hook/Hooks/FailedAttemptsLimit.php): countsAuthLogRecordrows withisDeniedfor the client IP within the lastauthFailedAttemptsPeriod(default 60 seconds,ConfigDataProvider.php:39); once the count is aboveauthMaxFailedAttemptNumber(default 10,:41; check atFailedAttemptsLimit.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 unlessauthUsernameFailedAttemptsLimitEnabled; when on, afterauthMaxUsernameFailedAttemptNumber(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 byauthLogDisabled, and identifies the client by the server param named inipAddressServerParam(defaultREMOTE_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 inCore,Tools,Controllersand the defaults finds none), includingLeadCapture. 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 fromdata/config.php. The params that are not editable in the admin UI are listed inResources/defaults/systemConfig.php(systemItems, from line 47;authFailedAttemptsPeriodandipAddressServerParamare 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'sGuardMiddlewarefrom the config block and delegates to it.SlimGuarditself cannot take a route map (its constructor takes noroutesorrouteResolver, slim-guard v1.6.0src/SlimGuard.php:58-77), whileGuardMiddlewaretakes both (src/GuardMiddleware.php:57-66), so buildingGuardMiddlewaredirectly is what makes route keys likePOST /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 toStarter.php, and could even ship as an extension. - Order: Slim runs the last-added middleware first.
Starter::start()adds global middleware atStarter.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'sBodyParsingMiddleware(no reference inapplication/), 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 (
redisinsystemConfig.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 runApi::classand thereforeStarter::start(), so one registration covers both. The non-Slim entry points (public/index.php?entryPoint=...such asDownload,Image,LeadCaptureForm, andpublic/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-AuthorizationandAuthorizationkept out of the logs, a behavior rule that bans a client after repeated 401 responses onGET /api/v1/App/userand on the API prefix, a rate limit onPOST /api/v1/LeadCapture/and the two password routes. A honeypot trap field on the public lead capture form would need a hidden field rendered byFormService, so I would leave that decision to you. - Tests: the repo's own tools,
vendor/bin/phpunit tests/unitandvendor/bin/phpstan(.github/workflows/test.yml), plus an integration test that sends a hostile and a normal request throughStarterwith 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-guardv1.6.1 installs next toslim/slim^4.15 (4.15.3),slim/psr7^1,guzzlehttp/psr7^2.13,psr/http-message^2.0,psr/container^1.1 andpsr/log~3, which are the constraints in EspoCRM'scomposer.json. It needs PHP 8.2 or later (EspoCRM needs 8.3 to 8.5). It addsrennf93/guard-core-php(no composer dependencies of its own) and the PSR interface packages. Adding a package tocomposer.jsonis your call. - Body handling: the adapter reads at most the first 256 KiB of a request body for screening (
MAX_BODY_BYTES = 262144in psr15-guardsrc/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 wayRequestWrapper::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-phpAGENTS.mdlists "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
- Would you consider an opt-in, default-off integration like this at all, in core or only as an extension?
- 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?
- 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
- Nessun Dockerfile né file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di espocrm/espocrm
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
espocrm/espocrm#3845 · 5 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
Browser end-to-end tests for core CRM flowsForse già presa @harishr-testmuai l’ha presa 16 giorni fa. Aperta
Difficoltà 3/5 1-2 giorni Idoneità per principianti 55/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 48/100
espocrm/espocrm#3822 · 2 reazioni ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 46/100
espocrm/espocrm#3763 · 1 reazione ·
I maintainer di solito rispondono entro 1 giorno
-
ACL merge crash when user has two roles with boolean vs object values for the same scopeForse già presa @evan70 l’ha presa 67 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 25/100
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di espocrm/espocrm
Issue simili
-
enhancement
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 78/100
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
grokability/snipe-it#19996 ·
I maintainer di solito rispondono entro 1 giorno
-
enhancement
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 82/100
I maintainer di solito rispondono entro 1 giorno
-
Add a dark themeApertagood first issue laravel-appointment-reminders
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
-
Text to Speech fails on Windows: the temp file path loses its backslashes when stored in post metaForse già presa @azizulhasan l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
I maintainer di solito rispondono entro 1 giorno